Initial commit: ACE-Step UI - Open source music generation interface
@@ -0,0 +1,25 @@
|
||||
# ACE-Step UI Configuration
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# Database (SQLite)
|
||||
DATABASE_PATH=./data/acestep.db
|
||||
|
||||
# ACE-Step API (local)
|
||||
ACESTEP_API_URL=http://localhost:8001
|
||||
|
||||
# Storage
|
||||
AUDIO_DIR=./public/audio
|
||||
|
||||
# Frontend
|
||||
FRONTEND_URL=http://localhost:5173
|
||||
VITE_API_URL=http://localhost:3001
|
||||
|
||||
# JWT Secret (for local session management)
|
||||
JWT_SECRET=ace-step-ui-local-secret
|
||||
|
||||
# Optional: Pexels API Key (for video backgrounds)
|
||||
# Get a free API key at https://www.pexels.com/api/
|
||||
PEXELS_API_KEY=
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
*.log
|
||||
@@ -0,0 +1,321 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>AudioMass - About</title>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link href="/ico.ico" rel="shortcut icon">
|
||||
|
||||
<style>
|
||||
body{
|
||||
padding:0;margin:0;
|
||||
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
|
||||
color:rgb(5, 10, 5);
|
||||
background:#fefefe
|
||||
}
|
||||
#w{
|
||||
width: 90%;
|
||||
max-width:960px;
|
||||
margin:0 auto
|
||||
}
|
||||
|
||||
#w > div{
|
||||
padding:20px 0 0 0
|
||||
}
|
||||
|
||||
p{
|
||||
line-height:21px;
|
||||
font-size:16px;
|
||||
margin-bottom:1.2em
|
||||
}
|
||||
|
||||
h1{
|
||||
font-size: 26px;
|
||||
margin-top:35px
|
||||
}
|
||||
|
||||
h5{
|
||||
font-size:1em;
|
||||
margin-bottom:0
|
||||
}
|
||||
|
||||
.top{
|
||||
max-width:100%;
|
||||
outline:0;
|
||||
display:inline-block
|
||||
}
|
||||
|
||||
.mid{
|
||||
max-width:504px;
|
||||
width: 90%;
|
||||
outline:0;
|
||||
margin-bottom:16px;
|
||||
display:inline-block
|
||||
}
|
||||
|
||||
.nomarg{
|
||||
margin-bottom:0
|
||||
}
|
||||
|
||||
pre {
|
||||
padding:12px 16px;
|
||||
display:inline-block;
|
||||
border-radius:6px;
|
||||
background:#e1f3e9;
|
||||
text-shadow:0 1px #fff;
|
||||
border-bottom:1px solid #aaa;
|
||||
max-width:90%;
|
||||
overflow:scroll
|
||||
}
|
||||
|
||||
ul {
|
||||
padding:0;
|
||||
list-style:none
|
||||
}
|
||||
|
||||
li {
|
||||
padding:0 0 3px 0;
|
||||
color:#333;
|
||||
font-size:0.92em
|
||||
}
|
||||
|
||||
video {
|
||||
max-width:660px;
|
||||
width:90%;
|
||||
}
|
||||
|
||||
::-moz-selection { background: #080808; color:#fff; text-shadow:none }
|
||||
::selection { background: #080808; color:#fff; text-shadow:none }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="w">
|
||||
|
||||
|
||||
<div>
|
||||
<h1>Introducing AudioMass (<a href="https://audiomass.co" target="_blank">https://audiomass.co</a>) an open-source web based audio and waveform editing tool.</h1>
|
||||
|
||||
|
||||
<img class="top" src="/about/audiomass_top.jpg" />
|
||||
|
||||
|
||||
<p>AudioMass lets you record, or use your existing audio tracks, and modify them by trimming, cutting, pasting or applying a plethora of effects, from compression and paragraphic equalizers to reverb, delay and distortion fx. AudioMass also supports more than 20 hotkeys combinations and a dynamic responsive interface to ensure ease of use and that your producivity remains high.
|
||||
it is written solely in plain old-school javascript, weights approximately 65kb and has no backend or framework dependencies.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_support.jpg" />
|
||||
<p>It also has very good browser and device support.</p>
|
||||
|
||||
|
||||
<h5>:: Feature List ::</h5>
|
||||
|
||||
<ul>
|
||||
<li>Loading Audio, navigating the waveform, zoom and pan</li>
|
||||
<li>Visualization of frequency levels</li>
|
||||
<li>Peak and distortion signaling</li>
|
||||
<li>Cutting/Pasting/Trimming parts of the audio</li>
|
||||
<li>Inverting and Reversing Audio</li>
|
||||
<li>Exporting to mp3</li>
|
||||
<li>Modifying volume levels</li>
|
||||
<li>Fade In/Out</li>
|
||||
<li>Compressor</li>
|
||||
<li>Normalization</li>
|
||||
<li>Reverb</li>
|
||||
<li>Delay</li>
|
||||
<li>Distortion</li>
|
||||
<li>Pitch Shift</li>
|
||||
<li>Keeps track of states so you can undo mistakes</li>
|
||||
<li>Offline support!</li>
|
||||
</ul>
|
||||
|
||||
<p>And all this, in only 83kb of JS!</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<h3>Getting Started</h3>
|
||||
|
||||
|
||||
<p>To get started, drag and drop an audio file, or try the included sample.<br />
|
||||
Once the file is loaded and you can view the waveform, zoom in, pan around, or select a region.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_2.jpg" />
|
||||
|
||||
|
||||
<h3>Recording Audio</h3>
|
||||
|
||||
<p>To record audio, simply press the Recording button, or the <i>[R]</i> key.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_3.jpg" />
|
||||
|
||||
|
||||
<h3>Exporting to mp3</h3>
|
||||
|
||||
<p>In order to export back to mp3, click on 'File', then 'Export to mp3', and follow the modal's instructions.</p>
|
||||
|
||||
<img class="mid" src="/about/audiomass_4.jpg" />
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
<div>
|
||||
<h3>The story behind AudioMass. And a short rant on web interfaces.</h3>
|
||||
|
||||
<p>I wrote AudioMass back in June 2018 and it stayed dormant on my hard disk until I decided to share it with the world today (July 13th, 19 -- but you might be seeing this in 2020. Hi!!).
|
||||
It started as a personal small tool for quick visualization of waveforms. Later I added the ability to cut/copy/paste as well as fade in and out. And soon after good 'ol feature creep and perfectionism took over! Soon after, it turned to a challenge to see how close to a full-featured waveform editor it can get, whilst maintaining acceptable performance and small filesize.</p>
|
||||
|
||||
<p>In general I am a big fan of the interfaces of DAWs <i>(Digital Audio Workstations)</i>, they are extremely, complex, intricate, versatile, and they manage to remain visually pleasant even through their infinite options and knobs. Many times I feel the web has taken a very wrong turn, as amazing interfaces such as...
|
||||
|
||||
<p class="nomarg">Sonar</p>
|
||||
<img class="mid" src="/about/sonar.jpg" />
|
||||
|
||||
<br />
|
||||
<p class="nomarg">Fruity loops</p>
|
||||
<img class="mid" src="/about/fruity.png" />
|
||||
|
||||
<br />
|
||||
|
||||
<p>Existed for more than 10-15 years now, while we are struggling with animating some rectangles for 60fps... So for AudioMass I wanted to try and make a fast and performant interface. Drawing inspiration from the examples I mentioned earlier rather than the tradiional web development practices. This is my unconvincing but truthfull excuse as to why the code is ugly; it is focused on being fast and getting the job done, with little regard on structure.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
|
||||
<h3>Building the interface</h3>
|
||||
|
||||
<p>Let's say we have a <i>"PLAY"</i> button and when we press it the track begins to play. I suppose we would want the button's color and state to reflect that the track is now playing. So naively we would do something like;</p>
|
||||
|
||||
|
||||
<pre><code>btn.onclick = function () {
|
||||
this.classList.add ('active');
|
||||
};</code></pre>
|
||||
|
||||
<p>But what happens if we have a hotkey that triggers the same action? Let's say we press <i>[SPACEBAR]</i> and the track begins to play. Do we modify that button's class in the spacebar's handler?</p>
|
||||
|
||||
<pre><code>document.onkeypress = function ( e ) {
|
||||
if ( e.keyCode === 32) {
|
||||
e.preventDefault ();
|
||||
|
||||
document.querySelector ('.playbtn').classList.add ('active');
|
||||
}
|
||||
};</code></pre>
|
||||
|
||||
<p>And what happens, if there are 2 buttons, or one gets dynamically removed? Do we do selectAll and iterate? Hmmm...<br />
|
||||
And if the track is playing and we hit <i>[SPACEBAR]</i> or the play button again, we need to stop playing. What do we do then? You can see how this becomes messy very quickly as everything gets very tighlty coupled together in a big dependency ball.</p>
|
||||
|
||||
|
||||
|
||||
<p>Introducing the observer pattern. Actions are represented by events. So the above logic and be expressed as;</p>
|
||||
|
||||
|
||||
<pre><code>btn.onclick = function () {
|
||||
FireEvent ('RequestTogglePlay');
|
||||
};
|
||||
|
||||
On ('WillPlay', function () {
|
||||
btn.classList.add ('active');
|
||||
});
|
||||
|
||||
On ('WillStop', function () {
|
||||
btn.classList.remove ('active');
|
||||
});
|
||||
|
||||
|
||||
|
||||
document.onkeypress = function ( e ) {
|
||||
if ( e.keyCode === 32) {
|
||||
e.preventDefault ();
|
||||
|
||||
FireEvent ('RequestTogglePlay');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
On ('RequestTogglePlay', function () {
|
||||
if (track.is_playing) {
|
||||
FireEvent ('WillStop');
|
||||
track.stop ();
|
||||
}
|
||||
else {
|
||||
FireEvent ('WillPlay');
|
||||
track.play ();
|
||||
}
|
||||
});
|
||||
|
||||
track.onPlayStart = function () {
|
||||
FireEvent ('DidPlay');
|
||||
};
|
||||
track.onPlayStop = function () {
|
||||
FireEvent ('DidStop');
|
||||
};
|
||||
</code></pre>
|
||||
|
||||
<p>Now this is completely decoupled and dependency free! The button will set its state according to the events it receives, and both the button and the spacebar key rely on the same mechanisms.
|
||||
You may notice the vocabulary we are using. <i>"Request"</i>, <i>"Will"</i> and <i>"Did"</i>. These are arbitrarilly chosen to impose some extra structure.<br /> <i>"Request"</i> denotes intent to perform an action, it is not guaranteed that the action will execute as there might be conditions preventing it (eg unitialized or still loading objects). <i>"Will"</i> means that the conditions passed and we are attempting to perform the action. And <i>"Did"</i> means that the action just got performed.<br />
|
||||
It might be a bit too verbose, but it worked very well for structuring AudioMass's interface.</p>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h3>Dockable UI</h3>
|
||||
|
||||
<p>One thing I love about DAW interfaces, is that every window can be pulled out of the main host. I fondly remember having 3 screens full of VST plugins. So can we do the same in the browser?</p>
|
||||
|
||||
|
||||
<video src="/about/dock_ui.mp4" autoplay muted playsinline controls loop></video>
|
||||
|
||||
|
||||
<p>Yes! And it is using some of the oldest tricks in the book. Essentially we create a pop-up window with <i>window.open</i> and just pass buffers to its <i>documentWindow</i> object. Surprisingly it is quite performant on all browsers except IE Edge. I believe they are serializing in ascii or base64 every packet or something. Also Chrome has an interesting bug where you can't pass more than 512 byte buffers.</p>
|
||||
|
||||
<p>So for the undocked window, we call <i>window.open</i>. But how do we make it work when it is docked? It would be quite cumbersome to write the same functionality twice, once as a standalone page, and once as an in-page script. Luckily we can avoid that completely, and re-use the same page by using iframes.</p>
|
||||
|
||||
<p>The only difference is that the undocked version uses <i>window.opener</i> to refference its parent, whereas the iframe uses <i>window.parent</i>.</p>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h3>Future work and performance considerations</h3>
|
||||
|
||||
<p>The next big feature that is planned, is multitrack support. The ability to mix tracks and different sounds is currently lacking and its usefulness as it stands is limited.</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>There is also a lot of room for improvement in almost all aspects.</p>
|
||||
|
||||
<p>First of all we can further reduce the filesize by around 20kb by removing the library we are using for rendering the waveform. We use only a fraction of its functionality so there is no reason to include it all.</p>
|
||||
|
||||
<p>We can also optimize a lot the rendering of the waveform. I heavily modified the library used to compute and draw only the visible range. However it is still clearing and re-drawing all of the canvas at each frame. We can take advantage of 2d Context's translate calls, and shift the canvas around instead of redrawing all of the pixels.</p>
|
||||
|
||||
<p>We can also move some operations to a background thread, such as the filters processing so that the UI does not freeze when applying a long chain of effects.</p>
|
||||
|
||||
<br />
|
||||
|
||||
|
||||
<p>However, the biggest issue I encountered, is the Web Audio API itself. Every operation results in iterating over multiple long arrays per frame. Eventually the garbage collector fires and crackling is introduced. Only way to go around this is to use a small fftSize, but then the frequency range we have to work with is very narrow. Perhaps a pure WASM implementation would outperform trying to modify audio signals with JS. Only one way to find out I guess :) </p>
|
||||
<p>Additionally, <i>decodeAudioData</i> provides no progress callback, and no way of cancelling it. So if you attempt to load a huge audio file, you will waste resources until it gets processed. There is no way around this currently and it can get annoying if you push a big file by mistake.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<br /><br /><br />
|
||||
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
After Width: | Height: | Size: 757 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 135 KiB |
@@ -0,0 +1,107 @@
|
||||
(function (w, d) {
|
||||
'use strict';
|
||||
|
||||
var _v = '0.9',
|
||||
_id = -1;
|
||||
|
||||
function PKAE() {
|
||||
var q = this; // keeping track of current context
|
||||
|
||||
q.el = null; // reference of main html element
|
||||
q.id = ++_id; // auto incremental id
|
||||
q._deps = {}; // dependencies
|
||||
|
||||
w.PKAudioList[q.id] = q;
|
||||
|
||||
var events = {};
|
||||
|
||||
q.fireEvent = function (eventName, value, value2) {
|
||||
var group = events[eventName];
|
||||
if (!group) return (false);
|
||||
|
||||
var l = group.length;
|
||||
while (l-- > 0) {
|
||||
group[l] && group[l](value, value2);
|
||||
}
|
||||
};
|
||||
|
||||
q.listenFor = function (eventName, callback) {
|
||||
if (!events[eventName])
|
||||
events[eventName] = [callback];
|
||||
else
|
||||
events[eventName].unshift(callback);
|
||||
};
|
||||
|
||||
q.stopListeningFor = function (eventName, callback) {
|
||||
var group = events[eventName];
|
||||
if (!group) return (false);
|
||||
|
||||
var l = group.length;
|
||||
while (l-- > 0) {
|
||||
if (group[l] && group[l] === callback) {
|
||||
group[l] = null; break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
q.stopListeningForName = function (eventName) {
|
||||
var group = events[eventName];
|
||||
if (!group) return (false);
|
||||
events[eventName] = null;
|
||||
};
|
||||
|
||||
q.init = function (el_id) {
|
||||
var el = d.getElementById(el_id);
|
||||
if (!el) {
|
||||
console.log('invalid element');
|
||||
return;
|
||||
}
|
||||
q.el = el;
|
||||
|
||||
// init libraries
|
||||
q.ui = new q._deps.ui(q); q._deps.uifx(q);
|
||||
q.engine = new q._deps.engine(q);
|
||||
q.state = new q._deps.state(4, q);
|
||||
q.rec = new q._deps.rec(q);
|
||||
q.fls = new q._deps.fls(q);
|
||||
|
||||
if (w.location.href.split('local=')[1]) {
|
||||
var sess = w.location.href.split('local=')[1];
|
||||
|
||||
q.fls.Init(function () {
|
||||
q.fls.GetSession(sess, function (e) {
|
||||
if (e && e.id === sess) {
|
||||
q.engine.LoadDB(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Check for audioUrl parameter to load audio from URL
|
||||
if (w.location.href.split('audioUrl=')[1]) {
|
||||
var audioUrl = decodeURIComponent(w.location.href.split('audioUrl=')[1].split('&')[0]);
|
||||
|
||||
setTimeout(function () {
|
||||
if (audioUrl) {
|
||||
q.engine.LoadURL(audioUrl);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
return (q);
|
||||
};
|
||||
|
||||
// check if we are mobile and hide tooltips on hover
|
||||
q.isMobile = (/iphone|ipod|ipad|android/).test
|
||||
(navigator.userAgent.toLowerCase());
|
||||
};
|
||||
|
||||
!w.PKAudioList && (w.PKAudioList = []);
|
||||
|
||||
// ideally we do not want a global singleto refferencing our audio tool
|
||||
// but since this is a limited demo we can safely do it.
|
||||
w.PKAudioEditor = new PKAE();
|
||||
|
||||
PKAudioList.push(w.PKAudioEditor); // keeping track in the audiolist array of our instance
|
||||
|
||||
})(window, document);
|
||||
@@ -0,0 +1,49 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
_ "net/url"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
changeHeaderThenServe := func(h http.Handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("Access-Control-Allow-Origin", "*")
|
||||
|
||||
if strings.Contains(r.URL.Path, ".wasm") {
|
||||
w.Header().Add("Content-Type", "application/wasm")
|
||||
}
|
||||
|
||||
if strings.Contains(r.URL.Path, ".js") {
|
||||
var epoch = time.Unix(0, 0).Format(time.RFC1123)
|
||||
var noCacheHeaders = map[string]string{
|
||||
"Expires": epoch,
|
||||
"Cache-Control": "no-cache, private, max-age=0",
|
||||
"Pragma": "no-cache",
|
||||
"X-Accel-Expires": "0",
|
||||
}
|
||||
|
||||
for k, v := range noCacheHeaders {
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("Req:", r.Host, r.URL.Path)
|
||||
h.ServeHTTP(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
cmd := exec.Command("open", "-a", "Google Chrome", "http://localhost:5055/")
|
||||
cmd.Output()
|
||||
}()
|
||||
|
||||
fmt.Printf("\nListening on http://localhost:5055 \n\n")
|
||||
http.Handle("/", changeHeaderThenServe(http.FileServer(http.Dir("."))))
|
||||
panic(http.ListenAndServe(":5055", nil))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import SimpleHTTPServer
|
||||
import SocketServer
|
||||
PORT = 5055
|
||||
Handler = SimpleHTTPServer.SimpleHTTPRequestHandler
|
||||
httpd = SocketServer.TCPServer(("", PORT), Handler)
|
||||
print "serving at port", PORT
|
||||
httpd.serve_forever()
|
||||
@@ -0,0 +1,12 @@
|
||||
// Add this to app.js after line 78 (after the local= parameter check)
|
||||
|
||||
// Check for audioUrl parameter to load audio from URL
|
||||
if (w.location.href.split('audioUrl=')[1]) {
|
||||
var audioUrl = decodeURIComponent(w.location.href.split('audioUrl=')[1].split('&')[0]);
|
||||
|
||||
setTimeout(function () {
|
||||
if (audioUrl) {
|
||||
q.engine.LoadURL(audioUrl);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
CACHE MANIFEST
|
||||
# v1 - July 26, 2018
|
||||
# v2 - May 25, 2020
|
||||
|
||||
/index-cache.html
|
||||
/main.css
|
||||
/dist/wavesurfer.js
|
||||
/dist/plugin/wavesurfer.regions.js
|
||||
/oneup.js
|
||||
/app.js
|
||||
/keys.js
|
||||
/contextmenu.js
|
||||
/ui-fx.js
|
||||
/ui.js
|
||||
/modal.js
|
||||
/state.js
|
||||
/engine.js
|
||||
/actions.js
|
||||
/drag.js
|
||||
/recorder.js
|
||||
/welcome.js
|
||||
/fx-pg-eq.js
|
||||
/fx-auto.js
|
||||
/local.js
|
||||
/id3.js
|
||||
/lzma.js
|
||||
/lame.js
|
||||
/fonts/icomoon.ttf
|
||||
/fonts/icomoon.woff
|
||||
/test.mp3
|
||||
|
||||
|
||||
NETWORK:
|
||||
/index-cache.html
|
||||
|
||||
# Fallback content
|
||||
FALLBACK:
|
||||
. /index-cache.html
|
||||
@@ -0,0 +1,205 @@
|
||||
(function ( win, doc, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var activeMenu = [],
|
||||
namespace = win,
|
||||
contextStorage = {},
|
||||
_id = 0;
|
||||
|
||||
var closeEvent = [ 'mousedown', 'touchup' ];
|
||||
|
||||
/**
|
||||
* Goes through every single context instance and terminates
|
||||
* it.
|
||||
**/
|
||||
var closeContext = function ( e, force ) {
|
||||
if (!e) return ;
|
||||
if (activeMenu.length === 0) return ;
|
||||
|
||||
var el = e.target || e.srcElement;
|
||||
|
||||
if (!el || el.className.indexOf('_action') === -1 || force)
|
||||
{
|
||||
var l = activeMenu.length;
|
||||
while (l--) terminate (activeMenu[ l ]);
|
||||
activeMenu = [];
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Go through every children element of the context el,
|
||||
* and remove all listeners and added attributes, then remove it
|
||||
* from the dom also
|
||||
**/
|
||||
terminate = function( e ) {
|
||||
var children = e.currentMenu.getElementsByTagName('*'),
|
||||
len = children.length;
|
||||
|
||||
while( len-- )
|
||||
children[ len ].parentNode.removeChild( children[ len ] );
|
||||
|
||||
e.currentMenu.removeEventListener( closeEvent, stopPropagation );
|
||||
doc.body.removeChild( e.currentMenu );
|
||||
e.currentMenu = null;
|
||||
return false;
|
||||
},
|
||||
/** stop propagation func, so that we don't have to use anonymous funcs **/
|
||||
stopPropagation = function(e){ e.stopPropagation(); },
|
||||
openContext = function( e, x, y ) {
|
||||
|
||||
closeContext(null);
|
||||
activeMenu.push( e );
|
||||
|
||||
//go through all the options and make the div
|
||||
var div = doc.createElement('div'),
|
||||
a,
|
||||
marginOffset = 4,
|
||||
opts = e.options,
|
||||
leftOffset = x - marginOffset,
|
||||
topOffset = y - marginOffset,
|
||||
width = 0, height = 0;
|
||||
|
||||
div.className = "pk_contextMenu " + e.menuClass;
|
||||
div.id = e.token;
|
||||
|
||||
for( var i = 0, len = opts.length; i < len; ++i ) {
|
||||
if( opts[ i ].isHTML )
|
||||
{
|
||||
a = doc.createElement('div');
|
||||
a.innerHTML = opts[ i ].isHTML;
|
||||
div.appendChild( a );
|
||||
}
|
||||
else {
|
||||
a = doc.createElement('a');
|
||||
a.className = 'pk_ctx_action';
|
||||
a.cnt = 1;
|
||||
a.innerHTML = opts[ i ].name;
|
||||
a.callback = opts[ i ].callback;
|
||||
|
||||
a.addEventListener( 'click', a.callback, false );
|
||||
|
||||
div.appendChild( a );
|
||||
}
|
||||
}
|
||||
|
||||
e.currentMenu = div;
|
||||
div.addEventListener( closeEvent, stopPropagation, false );
|
||||
|
||||
doc.body.appendChild( div );
|
||||
|
||||
width = div.offsetWidth;
|
||||
height = div.offsetHeight;
|
||||
|
||||
if( win.innerWidth < ( leftOffset + width ) && win.innerHeight < ( topOffset + height ) )
|
||||
div.style.cssText = "top:" + ( topOffset - height ) + "px;left:" + ( leftOffset - width ) + "px;";
|
||||
else if( win.innerWidth < ( leftOffset + width ) )
|
||||
div.style.cssText = "top:" + ( topOffset ) + "px;left:" + ( leftOffset - width ) + "px;";
|
||||
else if( win.innerHeight < ( topOffset + height ) )
|
||||
div.style.cssText = "top:" + ( topOffset - height ) + "px;left:" + ( leftOffset ) + "px;";
|
||||
else
|
||||
div.style.cssText = "top:" + ( topOffset ) + "px;left:" + ( leftOffset ) + "px;";
|
||||
|
||||
if (e.onOpen) {
|
||||
e.onOpen ( e, div );
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
openMenu = function( e )
|
||||
{
|
||||
if (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
else {
|
||||
e = {pageX:0, pageY:0};
|
||||
}
|
||||
|
||||
// ----
|
||||
var instance = getInstance ( this );
|
||||
var pageX = e.pageX || e.clientX + doc.documentElement.scrollLeft;
|
||||
var pageY = e.pageY || e.clientY + doc.documentElement.scrollTop;
|
||||
|
||||
if (!instance) return false;
|
||||
|
||||
instance.curr_target = e.target || e.srcElement;
|
||||
|
||||
openContext ( instance, pageX, pageY );
|
||||
},
|
||||
getInstance = function( elem ) {
|
||||
return contextStorage[ elem.getAttribute( 'data-token' ) ];
|
||||
};
|
||||
|
||||
/**
|
||||
* Context Menu Constructor
|
||||
**/
|
||||
var contextMenu = namespace.contextMenu = function( elem, options ) {
|
||||
if (!(this instanceof contextMenu)) return new contextMenu( elem, options );
|
||||
if (!options) options = {};
|
||||
|
||||
var open_events = ['contextmenu', 'longpress'];
|
||||
|
||||
this.elem = elem;
|
||||
this.options = [];
|
||||
this.menuClass = options.className || 'pk_open';
|
||||
this.curr_target = null;
|
||||
|
||||
// modified context menu to open only when double click + no movement
|
||||
// if (elem) elem.addEventListener( 'contextmenu', openMenu, false );
|
||||
if (elem) elem.addEventListener( 'pk_ctxmn', openMenu, false );
|
||||
|
||||
this.token = ++_id;
|
||||
if (elem) elem.setAttribute( 'data-token', this.token );
|
||||
|
||||
contextStorage[ this.token ] = this;
|
||||
};
|
||||
/**
|
||||
* Wrapper to the private openMenu function
|
||||
**/
|
||||
contextMenu.prototype.open = function( e ) {
|
||||
openMenu.call( this.elem, e );
|
||||
};
|
||||
contextMenu.prototype.close = function( e ) {
|
||||
closeContext();
|
||||
};
|
||||
contextMenu.prototype.openWithToken = function( token, x, y ) {
|
||||
openContext( contextStorage[ token ], x||0, y||0 );
|
||||
};
|
||||
/**
|
||||
* Closes context and removes it fully
|
||||
**/
|
||||
contextMenu.prototype.destroy = function() {
|
||||
// this.elem.removeEventListener( 'contextmenu', openMenu );
|
||||
this.elem.removeEventListener( 'pk_ctxmn', openMenu );
|
||||
|
||||
closeContext();
|
||||
contextStorage[ this.token ] = null;
|
||||
|
||||
return false;
|
||||
},
|
||||
/**
|
||||
* Adds option
|
||||
* @param string name of the option
|
||||
* @param function to run when its chosen
|
||||
* @param if this is set, then append the HTML instead of its name in its position
|
||||
* @param initialization code to run when the object is appended to the dom
|
||||
**/
|
||||
contextMenu.prototype.addOption = function( name, callback, isHTML ) {
|
||||
var q = this;
|
||||
this.options.push({ "name" : name, "callback" : function( e ) {
|
||||
|
||||
callback && callback( q, q._open );
|
||||
closeContext( q, true );
|
||||
},
|
||||
"isHTML" : isHTML
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// todo touch controls too? ####
|
||||
doc.addEventListener( closeEvent[0], closeContext, false );
|
||||
doc.addEventListener( 'killCTX', closeContext, false );
|
||||
|
||||
|
||||
PKAE._deps.ContextMenu = contextMenu;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,178 @@
|
||||
(function( parent ) {
|
||||
'use strict';
|
||||
|
||||
/** parent object, set in the end of the selfcalling function **/
|
||||
var parent = parent || window,
|
||||
/** instance of File Reader **/
|
||||
reader,
|
||||
readFile,
|
||||
/**
|
||||
* Removes class from element
|
||||
* @param htmlObject target element
|
||||
* @param string "class to be removed"
|
||||
**/
|
||||
removeClass = function( el, value ) {
|
||||
if ( !el.className ) return false;
|
||||
var classes = el.className.split(' '),
|
||||
ret = [];
|
||||
for( var i = 0, l = classes.length; i < l; ++i )
|
||||
if( classes[i] != value )
|
||||
ret.push( classes[ i ] );
|
||||
el.className = ret.join(' ');
|
||||
};
|
||||
|
||||
if( !window.FileReader || !document.addEventListener ) {
|
||||
throw( "File API not supported" );
|
||||
readFile = function(){ throw( "File API not supported" ); };
|
||||
}
|
||||
else {
|
||||
reader = new FileReader();
|
||||
readFile = function ( file, callback, method ) {
|
||||
/** Error handler (throws error at the console) **/
|
||||
reader.onerror = function( e ) {
|
||||
var message,
|
||||
lut = [ "File not found.", "File coulnot be opened",
|
||||
"File couldnot be uploaded", "Couldnot read File", "File too large" ];
|
||||
// http://www.w3.org/TR/FileAPI/#ErrorDescriptions
|
||||
throw( lut[ ( e.target.error.code - 1 ) ] );
|
||||
},
|
||||
/** Success, calling the callback **/
|
||||
reader.onloadend = function( e ) {
|
||||
callback && callback( e.target.result, file.name );
|
||||
reader.onloadend = null;
|
||||
};
|
||||
|
||||
// the method is specified in the beginning of the file
|
||||
reader[ method ]( file );
|
||||
return false;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag n Drop Files module
|
||||
* @param HTMLElement, could be the Body
|
||||
* @param DOMElement/String, if a string is specified then a div will be built and appended to the body
|
||||
* with that String as its id. If a dom element is passed, that will be used instead. This object
|
||||
* acts as an overlay and the file should be droped to this object. If this object is null, then the first argument
|
||||
* will be used as the overlay.
|
||||
* @param Function, will be called with the file data, and the filename as its arguments
|
||||
* @param String, possible values "text, binarytext, arrabuffer" decides how the file will be read
|
||||
* if let null, defaults to text
|
||||
* @param String, class name to be added to the overlay (default is '__fadingIn')
|
||||
**/
|
||||
parent.dragNDrop = function( body, overlay_id, callback, method, _clss ) {
|
||||
var win = window;
|
||||
// check to see if we are using a mobile device - no need for dragNdrop in devices
|
||||
// that do not support it somehow yet
|
||||
if( ( 'ontouchstart' in window ) )
|
||||
return "mobile";
|
||||
|
||||
/** JS Object, used to define the file-reading method **/
|
||||
var method_lut = {
|
||||
'text' : 'readAsText',
|
||||
'binary' : 'readAsBinaryText',
|
||||
'arrayBuffer': 'readAsArrayBuffer'
|
||||
},
|
||||
/** class added/removed from overlay object **/
|
||||
clss = _clss ? _clss : "__fadingIn",
|
||||
method = method ? method_lut[ method ] : 'readAsText',
|
||||
/** how many events cast (dragenter/dragleave) **/
|
||||
entered = 0,
|
||||
/**
|
||||
* DOMElement sink for the drag events
|
||||
* if left unspecified then the body inherits the role
|
||||
**/
|
||||
overlay = !!overlay_id ? overlay_id : body,
|
||||
/**
|
||||
* (void) if the overlay_id specified is a string, then a div with that id is built
|
||||
* and appended to the body. Else the default is used
|
||||
**/
|
||||
_overlayBuilder = function() {
|
||||
if( typeof overlay_id === "string" )
|
||||
{
|
||||
var tmp = document.createElement( 'div' );
|
||||
overlay = document.createElement( 'div' );
|
||||
overlay.id = overlay_id;
|
||||
|
||||
tmp.innerHTML = "Drag n drop Files!";
|
||||
overlay.appendChild( tmp );
|
||||
|
||||
body.appendChild( overlay );
|
||||
tmp = null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* JS Object
|
||||
* The events Object contains various functions that control
|
||||
* the behavior of the events fired
|
||||
**/
|
||||
events = {
|
||||
/**
|
||||
* (void) Prevents default action and bubbling up
|
||||
**/
|
||||
silencer : function( e ) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation()
|
||||
},
|
||||
|
||||
/**
|
||||
* Shows message to drop file
|
||||
**/
|
||||
onDragEnter : function( e ) {
|
||||
// overlay.className += " " + clss;
|
||||
++entered;
|
||||
|
||||
setTimeout(function() {
|
||||
if( entered > 1 )
|
||||
entered = 1;
|
||||
}, 10 )
|
||||
},
|
||||
/**
|
||||
* Hides the overlay... twist included!
|
||||
**/
|
||||
onDragLeave : function( e ) {
|
||||
--entered;
|
||||
|
||||
if( entered <= 0 )
|
||||
{
|
||||
removeClass( overlay, clss );
|
||||
entered = 0;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Files dropped
|
||||
**/
|
||||
onDrop : function( e ) {
|
||||
// prevent the event from bubbling/firing default
|
||||
events.silencer( e );
|
||||
|
||||
// Hide the overlay
|
||||
removeClass( overlay, clss );
|
||||
entered = 0;
|
||||
|
||||
/** dropped files. **/
|
||||
var files = e.dataTransfer.files,
|
||||
len;
|
||||
|
||||
// If anything is wrong with the dropped files, exit.
|
||||
if( !files || !files.length )
|
||||
return false;
|
||||
|
||||
len = files.length;
|
||||
while( len-- )
|
||||
// iterate files array and load them
|
||||
readFile( files[ len ], callback, method );
|
||||
}
|
||||
};
|
||||
|
||||
(function init() {
|
||||
//_overlayBuilder();
|
||||
// events initialization
|
||||
body.parentNode.addEventListener( "dragenter", events.onDragEnter, false );
|
||||
body.addEventListener( "dragleave", events.onDragLeave, false );
|
||||
body.addEventListener( "dragover", events.silencer, false);
|
||||
body.addEventListener( "drop", events.onDrop, false);
|
||||
return false;
|
||||
})( body );
|
||||
};
|
||||
})( window );
|
||||
@@ -0,0 +1,220 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>window.update = function(){};</script>
|
||||
<title>AudioMass - Frequency Analysis</title>
|
||||
<style>
|
||||
html,body{height:100%}
|
||||
body{padding:0;margin:0;background:#111;position:relative;z-index:2;overflow:hidden;}
|
||||
div{position:absolute;top:50%;left:0;right:0;z-index:1;margin-top:-18px;text-align:center;user-select:none;
|
||||
font-size:28px;color:#191919;pointer-events:none;font-family:Arial}
|
||||
#e, #d, #f{
|
||||
cursor:pointer;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
display: block;
|
||||
height: 20px;
|
||||
z-index: 99999;
|
||||
background: #eaeaea;
|
||||
left:auto;
|
||||
border-radius:4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 9px;
|
||||
width: 82px;
|
||||
line-height: 20px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
opacity:0.9;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
.b #e, .b #d{
|
||||
top:12px;
|
||||
}
|
||||
|
||||
.b #e, .b #f{
|
||||
display:block;
|
||||
}
|
||||
|
||||
#e{
|
||||
right: 106px;
|
||||
width: 10px;
|
||||
line-height: 20px;
|
||||
padding: 0px 6px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
display:none;
|
||||
}
|
||||
|
||||
#f{
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
border-radius: 0;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
display:none;
|
||||
user-select:none;
|
||||
-moz-user-select:none;
|
||||
}
|
||||
|
||||
.c #f, #f:hover{
|
||||
background:#3C3C3C;
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fr" width="600" height="188" style="width:100%;height:100%;display:block"></canvas>
|
||||
<div>FREQUENCY ANALYSER</div>
|
||||
|
||||
<a id="d" onclick="dock()">DOCK INTERFACE</a>
|
||||
<a id="e" onclick="remove()">X</a>
|
||||
<a id="f" onmousedown="drag(event)"></a>
|
||||
|
||||
<script>
|
||||
var d = document;
|
||||
var w = window;
|
||||
var iframe = location.href.indexOf('?iframe') === -1 ? 0 : 1;
|
||||
var canvas = d.getElementById('fr');
|
||||
var ctx = canvas.getContext('2d', {alpha:true, antialias:false});
|
||||
|
||||
w.remove = function (){};
|
||||
if (iframe) {
|
||||
d.body.className = 'b';
|
||||
w.remove = function () {
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'eq', [1, 1]);
|
||||
};
|
||||
};
|
||||
|
||||
w.drag = function ( e ) {
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestDragI', 'eq');
|
||||
};
|
||||
|
||||
w.dock = function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) {
|
||||
return ;
|
||||
}
|
||||
|
||||
w.opener.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'eq', [1, 1]);
|
||||
w.close && w.close ();
|
||||
}
|
||||
else
|
||||
{
|
||||
var frm = w.parent.document.getElementById ('pk_fr' + 'eq');
|
||||
var t = 1;
|
||||
|
||||
if (frm && frm.getBoundingClientRect) {
|
||||
var rect = frm.getBoundingClientRect();
|
||||
|
||||
t = [(w.parent.screenLeft + rect.left + 100)||0, (w.parent.screenTop + rect.top + 25)||0];
|
||||
}
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'eq', [t, 0]);
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) return ;
|
||||
}
|
||||
|
||||
var WIDTH = w.innerWidth, HEIGHT = w.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
var bufferLength = 240;
|
||||
var value_changed = false;
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
|
||||
function draw( similarity ) {
|
||||
|
||||
var WIDTH = window.innerWidth, HEIGHT = window.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH || canvas.height != HEIGHT)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
var barWidth = (WIDTH / bufferLength);
|
||||
var barHeight;
|
||||
var x = 0;
|
||||
|
||||
for(var i = 0; i < bufferLength; i += 1) {
|
||||
barHeight = similarity[i * 2]; // ? similarity[i] : 0;
|
||||
|
||||
// bar height normalize with 255
|
||||
var newheight = ((barHeight / 256) * HEIGHT) >> 0;
|
||||
|
||||
ctx.fillStyle = 'rgb(' + (barHeight + 100) + ',50,50)';
|
||||
ctx.fillRect (x,HEIGHT - newheight, barWidth, newheight);
|
||||
x += barWidth;// + 1;
|
||||
}
|
||||
|
||||
value_changed = false;
|
||||
};
|
||||
w.draw = draw;
|
||||
|
||||
w.onunload = function () {
|
||||
w.destroy && w.destroy ( iframe );
|
||||
w.destroy = null;
|
||||
};
|
||||
|
||||
w.update = function (freq_arr) {
|
||||
if (!freq_arr)
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
else {
|
||||
if (value_changed) return ;
|
||||
value_changed = true;
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
draw (freq_arr);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var last_press = 0;
|
||||
document.addEventListener ('keypress', function ( e ) {
|
||||
if (e.keyCode !== 32) return ;
|
||||
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
if (e.timeStamp - last_press < 100) {
|
||||
return ;
|
||||
}
|
||||
|
||||
last_press = e.timeStamp;
|
||||
|
||||
if (!iframe) {
|
||||
w.opener && w.opener.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
else {
|
||||
w.parent && w.parent.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
});
|
||||
|
||||
}, 60);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,131 @@
|
||||
// FLAC worker for encoding audio using libflac.js
|
||||
|
||||
importScripts('libflac.js');
|
||||
|
||||
var flacEncoder;
|
||||
var FLAC_INITIALIZED = false;
|
||||
var sample_rate = 44100;
|
||||
var compression = 5; // Default compression (0-8)
|
||||
var channels = 1;
|
||||
var buffers = [];
|
||||
var bufIndex = 0;
|
||||
var first_buffer = true;
|
||||
var samples_left = null;
|
||||
var samples_right = null;
|
||||
|
||||
function convert(n) {
|
||||
var v = n < 0 ? n * 32768 : n * 32767;
|
||||
return Math.max(-32768, Math.min(32768, v));
|
||||
}
|
||||
|
||||
function initFLAC() {
|
||||
if (FLAC_INITIALIZED) return true;
|
||||
|
||||
// SAMPLERATE, CHANNELS, BPS, COMPRESSION, 0, VERIFY, BLOCK_SIZE);
|
||||
flacEncoder = Flac.create_libflac_encoder(sample_rate, channels, 16, compression, 0, true, 0);
|
||||
if (flacEncoder != 0) {
|
||||
var status = Flac.init_encoder_stream(flacEncoder, function(buffer, bytes) {
|
||||
buffers.push(new Uint8Array(buffer));
|
||||
bufIndex += buffer.byteLength;
|
||||
});
|
||||
|
||||
FLAC_INITIALIZED = true;
|
||||
return status == 0;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Int32Array(length);
|
||||
|
||||
var index = 0,
|
||||
inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
++inputIndex;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
onmessage = function(ev) {
|
||||
if (!ev.data) return;
|
||||
|
||||
if (ev.data.sample_rate) {
|
||||
sample_rate = ev.data.sample_rate / 1;
|
||||
compression = ev.data.flac_compression;
|
||||
channels = ev.data.channels / 1;
|
||||
|
||||
initFLAC();
|
||||
return;
|
||||
}
|
||||
|
||||
if (first_buffer) {
|
||||
samples_left = new Int16Array(ev.data, 0);
|
||||
first_buffer = false;
|
||||
|
||||
if (channels > 1) return;
|
||||
}
|
||||
|
||||
if (ev.data && channels > 1) {
|
||||
samples_right = new Int16Array(ev.data, 0);
|
||||
}
|
||||
|
||||
if (!FLAC_INITIALIZED) {
|
||||
postMessage({percentage: 0});
|
||||
return;
|
||||
}
|
||||
|
||||
// Progress update
|
||||
postMessage({percentage: 50});
|
||||
|
||||
// Process the audio data
|
||||
var interleaved = null;
|
||||
var samples = null;
|
||||
|
||||
if (channels > 1) {
|
||||
// Create interleaved buffer for stereo
|
||||
interleaved = interleave(samples_left, samples_right);
|
||||
samples = interleaved;
|
||||
} else {
|
||||
// Use mono buffer directly
|
||||
samples = samples_left;
|
||||
}
|
||||
|
||||
// Encode the audio data
|
||||
if (channels > 1) {
|
||||
Flac.FLAC__stream_encoder_process_interleaved(flacEncoder, samples, samples_left.length);
|
||||
} else {
|
||||
var tmp_samples = new Int32Array(samples_left.length);
|
||||
var tmp_index = 0;
|
||||
while (tmp_index < samples_left.length) {
|
||||
tmp_samples[tmp_index] = samples_left[tmp_index];
|
||||
++tmp_index;
|
||||
}
|
||||
Flac.FLAC__stream_encoder_process(flacEncoder, [tmp_samples], samples_left.length);
|
||||
}
|
||||
|
||||
// Finish encoding
|
||||
Flac.FLAC__stream_encoder_finish(flacEncoder);
|
||||
|
||||
// Combine all buffers into a single Uint8Array
|
||||
var outputData = new Uint8Array(bufIndex);
|
||||
var offset = 0;
|
||||
for (var i = 0; i < buffers.length; i++) {
|
||||
outputData.set(buffers[i], offset);
|
||||
offset += buffers[i].length;
|
||||
}
|
||||
|
||||
// Create blob and return
|
||||
var blob = new Blob([outputData], {type: 'audio/flac'});
|
||||
postMessage(blob);
|
||||
|
||||
// Clean up
|
||||
Flac.FLAC__stream_encoder_delete(flacEncoder);
|
||||
FLAC_INITIALIZED = false;
|
||||
buffers = [];
|
||||
bufIndex = 0;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?xml version="1.0" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" >
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<metadata>Generated by IcoMoon</metadata>
|
||||
<defs>
|
||||
<font id="icomoon" horiz-adv-x="1024">
|
||||
<font-face units-per-em="1024" ascent="960" descent="-64" />
|
||||
<missing-glyph horiz-adv-x="1024" />
|
||||
<glyph unicode=" " horiz-adv-x="512" d="" />
|
||||
<glyph unicode="" glyph-name="files-empty" d="M917.806 602.924c-22.21 30.292-53.174 65.7-87.178 99.704s-69.412 64.964-99.704 87.178c-51.574 37.82-76.592 42.194-90.924 42.194h-368c-44.114 0-80-35.888-80-80v-736c0-44.112 35.886-80 80-80h608c44.112 0 80 35.888 80 80v496c0 14.332-4.372 39.35-42.194 90.924zM785.374 657.374c30.7-30.7 54.8-58.398 72.58-81.374h-153.954v153.946c22.982-17.78 50.678-41.878 81.374-72.572v0zM896 16c0-8.672-7.328-16-16-16h-608c-8.672 0-16 7.328-16 16v736c0 8.672 7.328 16 16 16 0 0 367.956 0.002 368 0v-224c0-17.672 14.324-32 32-32h224v-496zM602.924 917.804c-51.574 37.822-76.592 42.196-90.924 42.196h-368c-44.112 0-80-35.888-80-80v-736c0-38.632 27.528-70.958 64-78.39v814.39c0 8.672 7.328 16 16 16h486.876c-9.646 7.92-19.028 15.26-27.952 21.804z" />
|
||||
<glyph unicode="" glyph-name="file-text2" d="M917.806 730.924c-22.212 30.292-53.174 65.7-87.178 99.704s-69.412 64.964-99.704 87.178c-51.574 37.82-76.592 42.194-90.924 42.194h-496c-44.112 0-80-35.888-80-80v-864c0-44.112 35.888-80 80-80h736c44.112 0 80 35.888 80 80v624c0 14.332-4.372 39.35-42.194 90.924zM785.374 785.374c30.7-30.7 54.8-58.398 72.58-81.374h-153.954v153.946c22.984-17.78 50.678-41.878 81.374-72.572zM896 16c0-8.672-7.328-16-16-16h-736c-8.672 0-16 7.328-16 16v864c0 8.672 7.328 16 16 16 0 0 495.956 0.002 496 0v-224c0-17.672 14.326-32 32-32h224v-624zM736 128h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32zM736 256h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32zM736 384h-448c-17.672 0-32 14.326-32 32s14.328 32 32 32h448c17.674 0 32-14.326 32-32s-14.326-32-32-32z" />
|
||||
<glyph unicode="" glyph-name="zoom-in" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM448 768h-128v-128h-128v-128h128v-128h128v128h128v128h-128z" />
|
||||
<glyph unicode="" glyph-name="zoom-out" d="M992.262 88.604l-242.552 206.294c-25.074 22.566-51.89 32.926-73.552 31.926 57.256 67.068 91.842 154.078 91.842 249.176 0 212.078-171.922 384-384 384-212.076 0-384-171.922-384-384s171.922-384 384-384c95.098 0 182.108 34.586 249.176 91.844-1-21.662 9.36-48.478 31.926-73.552l206.294-242.552c35.322-39.246 93.022-42.554 128.22-7.356s31.892 92.898-7.354 128.22zM384 320c-141.384 0-256 114.616-256 256s114.616 256 256 256 256-114.616 256-256-114.614-256-256-256zM192 640h384v-128h-384z" />
|
||||
<glyph unicode="" glyph-name="hammer" d="M1009.996 131.024l-301.544 301.544c-18.668 18.668-49.214 18.668-67.882 0l-22.626-22.626-184 184 302.056 302.058h-320l-142.058-142.058-14.060 14.058h-67.882v-67.882l14.058-14.058-206.058-206.060 160-160 206.058 206.058 184-184-22.626-22.626c-18.668-18.668-18.668-49.214 0-67.882l301.544-301.544c18.668-18.668 49.214-18.668 67.882 0l113.136 113.136c18.67 18.666 18.67 49.214 0.002 67.882z" />
|
||||
<glyph unicode="" glyph-name="play3" d="M192 832l640-384-640-384z" />
|
||||
<glyph unicode="" glyph-name="pause2" d="M128 832h320v-768h-320zM576 832h320v-768h-320z" />
|
||||
<glyph unicode="" glyph-name="stop2" d="M128 832h768v-768h-768z" />
|
||||
<glyph unicode="" glyph-name="backward2" d="M576 800v-320l320 320v-704l-320 320v-320l-352 352z" />
|
||||
<glyph unicode="" glyph-name="forward3" d="M512 96v320l-320-320v704l320-320v320l352-352z" />
|
||||
<glyph unicode="" glyph-name="first" d="M128 64v768h128v-352l320 320v-320l320 320v-704l-320 320v-320l-320 320v-352z" />
|
||||
<glyph unicode="" glyph-name="last" d="M896 832v-768h-128v352l-320-320v320l-320-320v704l320-320v320l320-320v352z" />
|
||||
<glyph unicode="" glyph-name="previous2" d="M256 64v768h128v-352l320 320v-704l-320 320v-352z" />
|
||||
<glyph unicode="" glyph-name="next2" d="M768 832v-768h-128v352l-320-320v704l320-320v352z" />
|
||||
<glyph unicode="" glyph-name="loop" d="M128 640h640v-192l256 256-256 256v-192h-768v-384h128zM896 256h-640v192l-256-256 256-256v192h768v384h-128z" />
|
||||
<glyph unicode="" glyph-name="scissors" d="M913.826 280.306c-66.684 104.204-181.078 150.064-255.51 102.434-6.428-4.116-12.334-8.804-17.744-13.982l-79.452 124.262 183.462 287.972c15.016 27.73 20.558 60.758 13.266 93.974-6.972 31.75-24.516 58.438-48.102 77.226l-12.278 7.808-217.468-340.114-217.47 340.114-12.276-7.806c-23.586-18.79-41.13-45.476-48.1-77.226-7.292-33.216-1.75-66.244 13.264-93.974l183.464-287.972-79.454-124.262c-5.41 5.178-11.316 9.868-17.744 13.982-74.432 47.63-188.826 1.77-255.51-102.434-66.68-104.2-60.398-227.286 14.032-274.914 74.43-47.632 188.824-1.77 255.508 102.432l164.286 257.87 164.288-257.872c66.684-104.202 181.078-150.064 255.508-102.432 74.428 47.63 80.71 170.716 14.030 274.914zM234.852 159.57c-30.018-46.904-68.534-69.726-94.572-75.446-0.004 0-0.004 0-0.004 0-8.49-1.868-20.294-3.010-28.324 2.128-8.898 5.694-14.804 20.748-15.8 40.276-1.616 31.644 9.642 68.836 30.888 102.034 30.014 46.906 68.53 69.726 94.562 75.444 8.496 1.866 20.308 3.010 28.336-2.126 8.898-5.694 14.802-20.75 15.798-40.272 1.618-31.65-9.64-68.84-30.884-102.038zM480 448c-17.672 0-32 14.328-32 32s14.328 32 32 32 32-14.328 32-32-14.328-32-32-32zM863.85 126.53c-0.996-19.528-6.902-34.582-15.8-40.276-8.030-5.138-19.834-3.996-28.324-2.128 0 0 0 0-0.004 0-26.040 5.718-64.554 28.542-94.572 75.446-21.244 33.198-32.502 70.388-30.884 102.038 0.996 19.522 6.9 34.578 15.798 40.272 8.028 5.136 19.84 3.992 28.336 2.126 26.034-5.716 64.548-28.538 94.562-75.444 21.246-33.198 32.502-70.39 30.888-102.034z" />
|
||||
</font></defs></svg>
|
||||
|
After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,457 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var _pid = 0;
|
||||
var _aid = 0;
|
||||
|
||||
function FXAutomation ( app, filter_modal, val_cb, preview_cb ) {
|
||||
var q = this;
|
||||
|
||||
q.modal = filter_modal;
|
||||
q.app = app;
|
||||
q.wv = app.engine.wavesurfer;
|
||||
q.points = {};
|
||||
q.act = null;
|
||||
q.act_point = null;
|
||||
q.in_auto = false;
|
||||
q.rbuff = null;
|
||||
|
||||
q.btn_auto = _make_btn_auto ( q );
|
||||
|
||||
q.GetValue = function () {
|
||||
var data = [];
|
||||
|
||||
var inputs = q.modal.el_body.getElementsByTagName('input');
|
||||
var plen = q.points.length;
|
||||
|
||||
for (var i = 0; i < inputs.length; ++i)
|
||||
{
|
||||
var curr = inputs[i];
|
||||
if (q.points[curr.id])
|
||||
{
|
||||
var arr = [];
|
||||
var p = q.points[curr.id];
|
||||
for (var j = 0; j < p.length; ++j)
|
||||
{
|
||||
var tmp = {
|
||||
time: p[j].time,
|
||||
val: p[j].val
|
||||
};
|
||||
arr.push(tmp);
|
||||
val_cb && val_cb (tmp, curr);
|
||||
}
|
||||
data.push (arr);
|
||||
}
|
||||
else
|
||||
{
|
||||
var tmp = {
|
||||
val: curr.value
|
||||
};
|
||||
|
||||
data.push (tmp);
|
||||
val_cb && val_cb (tmp, curr);
|
||||
}
|
||||
}
|
||||
|
||||
return (data);
|
||||
};
|
||||
|
||||
q.cw = 500;
|
||||
q.ch = 200;
|
||||
var els = _make_canvas ( q, q.cw, q.ch );
|
||||
q.canvas = els[0];
|
||||
q.ctx = els[1];
|
||||
|
||||
|
||||
var _fillstyle = '#d9d955';
|
||||
q.Render = function () {
|
||||
var ctx = q.ctx;
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
|
||||
if (q.rbuff)
|
||||
q.app.engine.GetWave (q.rbuff, 500, 200, null, null, q.canvas, q.ctx);
|
||||
|
||||
// ctx.clearRect (0, 0, q.cw, q.ch);
|
||||
ctx.fillStyle = _fillstyle;
|
||||
ctx.strokeStyle = '#FF0000';
|
||||
|
||||
if (!q.act) return ;
|
||||
|
||||
ctx.beginPath ();
|
||||
ctx.moveTo ( 0, ch / 2 );
|
||||
var last_y = ch / 2;
|
||||
|
||||
for (var o = 0; o < q.points[q.act.id].length; ++o)
|
||||
{
|
||||
var curr = q.points[q.act.id][ o ];
|
||||
|
||||
var center_x = curr.ax;
|
||||
var center_y = curr.ay;
|
||||
|
||||
ctx.lineTo ( center_x, center_y );
|
||||
last_y = center_y;
|
||||
}
|
||||
|
||||
ctx.lineTo ( cw, last_y );
|
||||
ctx.stroke ();
|
||||
|
||||
var radius = 6;
|
||||
for (var o = 0; o < q.points[q.act.id].length; ++o)
|
||||
{
|
||||
var curr = q.points[q.act.id][ o ];
|
||||
|
||||
var center_x = curr.ax;
|
||||
var center_y = curr.ay;
|
||||
|
||||
ctx.beginPath ();
|
||||
ctx.arc (center_x, center_y, radius, 0, 2 * Math.PI, false);
|
||||
|
||||
if (curr === q.act_point) {
|
||||
ctx.shadowBlur = 24;
|
||||
|
||||
if (curr._on)
|
||||
ctx.fillStyle = '#fff';
|
||||
else
|
||||
ctx.fillStyle = '#686868';
|
||||
|
||||
ctx.stroke ();
|
||||
ctx.fill ();
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.fillStyle = _fillstyle;
|
||||
}
|
||||
else if (curr._hov) {
|
||||
|
||||
if (curr._on)
|
||||
ctx.fillStyle = 'blue';
|
||||
else
|
||||
ctx.fillStyle = 'darkblue';
|
||||
|
||||
ctx.stroke ();
|
||||
ctx.fill ();
|
||||
|
||||
ctx.fillStyle = _fillstyle;
|
||||
}
|
||||
else if (curr._on) {
|
||||
ctx.fill ();
|
||||
}
|
||||
else {
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.fill ();
|
||||
ctx.fillStyle = _fillstyle;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
_make_controls ( q );
|
||||
|
||||
// -------
|
||||
function _make_controls ( q ) {
|
||||
var click_time = 0;
|
||||
q.canvas.addEventListener ('click', function ( e ) {
|
||||
if (!q.act) return;
|
||||
if (e.timeStamp - click_time < 260)
|
||||
{
|
||||
var bounds = q.canvas.getBoundingClientRect ();
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
var posx = e.clientX - bounds.left;
|
||||
var posy = e.clientY - bounds.top;
|
||||
|
||||
var rel_x = posx / cw;
|
||||
var rel_y = posy / ch;
|
||||
|
||||
if (!q.points[q.act.id]) q.points[q.act.id] = [];
|
||||
|
||||
var duration;
|
||||
var region = q.wv.regions.list[0];
|
||||
if (region) {
|
||||
duration = region.end - region.start;
|
||||
} else {
|
||||
duration = q.wv.getDuration();
|
||||
}
|
||||
|
||||
q.points[q.act.id].push ({
|
||||
// el:q.act.el,
|
||||
id: ++_pid,
|
||||
x: rel_x,
|
||||
y: rel_y,
|
||||
ax: rel_x * cw,
|
||||
ay: rel_y * ch,
|
||||
time: duration * rel_x,
|
||||
val : ((1 - rel_y) * (q.act.max - q.act.min)) + q.act.min,
|
||||
_on: true,
|
||||
_hov: false,
|
||||
});
|
||||
|
||||
q.points[q.act.id].sort( _compare );
|
||||
q.act_point = q.points[q.act.id][q.points[q.act.id].length - 1];
|
||||
|
||||
//_process ( q, q.wv.backend.buffer );
|
||||
|
||||
q.Render ();
|
||||
// ----
|
||||
}
|
||||
|
||||
click_time = e.timeStamp;
|
||||
}, false);
|
||||
|
||||
var is_dragging = false;
|
||||
var skip = 3;
|
||||
q.canvas.addEventListener ('mousemove', function ( e ) {
|
||||
if (!is_dragging || !q.act_point) return ;
|
||||
|
||||
var ex = 0;
|
||||
var ey = 0;
|
||||
|
||||
if (e.touches) {
|
||||
if (e.touches.length > 1) { return ; }
|
||||
|
||||
ex = e.touches[0].clientX;
|
||||
ey = e.touches[0].clientY;
|
||||
} else {
|
||||
ex = e.clientX;
|
||||
ey = e.clientY;
|
||||
}
|
||||
|
||||
var bounds = q.canvas.getBoundingClientRect ();
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
|
||||
var posx = ex - bounds.left;
|
||||
var posy = ey - bounds.top;
|
||||
|
||||
var rel_x = posx / cw;
|
||||
var rel_y = posy / ch;
|
||||
|
||||
q.act_point.ax = posx;
|
||||
q.act_point.ay = posy;
|
||||
|
||||
q.act_point.x = rel_x;
|
||||
q.act_point.y = rel_y;
|
||||
|
||||
var duration;
|
||||
var region = q.wv.regions.list[0];
|
||||
if (region) {
|
||||
duration = region.end - region.start;
|
||||
} else {
|
||||
duration = q.wv.getDuration();
|
||||
}
|
||||
|
||||
q.act_point.time = duration * rel_x;
|
||||
q.act_point.val = ((1 - rel_y) * q.act.max - q.act.min) + q.act.min;
|
||||
|
||||
q.Render ();
|
||||
|
||||
if (--skip === 0) {
|
||||
skip = 4;
|
||||
_process ( q, q.wv.backend.buffer );
|
||||
}
|
||||
});
|
||||
|
||||
q.canvas.addEventListener ('mousedown', function ( e ) {
|
||||
is_dragging = false;
|
||||
if (!q.act) return ;
|
||||
|
||||
var bounds = q.canvas.getBoundingClientRect ();
|
||||
var cw = q.cw;
|
||||
var ch = q.ch;
|
||||
|
||||
var posx = e.clientX - bounds.left;
|
||||
var posy = e.clientY - bounds.top;
|
||||
|
||||
var dist_x = e.is_touch ? 20 : 10;
|
||||
var dist_y = e.is_touch ? 20 : 9;
|
||||
|
||||
if (!q.points[q.act.id]) q.points[q.act.id] = [];
|
||||
|
||||
for (var o = 0; o < q.points[q.act.id].length; ++o)
|
||||
{
|
||||
var curr = q.points[q.act.id][ o ];
|
||||
if ( Math.abs (curr.ax - posx) < dist_x && Math.abs (curr.ay - posy) < dist_y)
|
||||
{
|
||||
is_dragging = true;
|
||||
q.act_point = curr;
|
||||
q.Render ();
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!is_dragging)
|
||||
{
|
||||
q.act_point = null;
|
||||
q.Render ();
|
||||
}
|
||||
});
|
||||
|
||||
q.canvas.addEventListener ('mouseup', function ( e ) {
|
||||
is_dragging = false;
|
||||
});
|
||||
|
||||
|
||||
var act_el = null;
|
||||
q.modal.el_body.addEventListener ('mouseover', function(e) {
|
||||
if (!q.in_auto) return ;
|
||||
if (e.target.tagName === 'INPUT') {
|
||||
e.target.classList.add ('pk_aut');
|
||||
}
|
||||
});
|
||||
q.modal.el_body.addEventListener ('mouseout', function(e) {
|
||||
if (!q.in_auto) return ;
|
||||
if (e.target.tagName === 'INPUT') {
|
||||
e.target.classList.remove ('pk_aut');
|
||||
}
|
||||
});
|
||||
q.modal.el_body.addEventListener ('click', function(e) {
|
||||
if (!q.in_auto) return ;
|
||||
if (e.target.classList.contains ('pk_aut'))
|
||||
{
|
||||
if (act_el) {
|
||||
act_el.classList.remove ('pk_aut_act');
|
||||
act_el = null;
|
||||
}
|
||||
|
||||
e.target.classList.add ('pk_aut_act');
|
||||
act_el = e.target;
|
||||
|
||||
if (!e.target.id) e.target.id = 'pk' + (++_aid);
|
||||
|
||||
q.act = {
|
||||
id: e.target.id,
|
||||
el: e.target,
|
||||
type:e.target.range,
|
||||
min:e.target.min/1,
|
||||
max:e.target.max/1,
|
||||
step:e.target.step/1
|
||||
};
|
||||
|
||||
if (!q.points[q.act.id]) q.points[q.act.id] = [];
|
||||
|
||||
q.Render ();
|
||||
}
|
||||
|
||||
// console.log( 'click ', e.target );
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
function _make_btn_auto ( q ) {
|
||||
var btn_automate = d.createElement ('a');
|
||||
btn_automate.className = 'pk_modal_a_bottom';
|
||||
btn_automate.innerHTML = 'AUTOMATE';
|
||||
|
||||
var in_auto = false;
|
||||
btn_automate.onclick = function () {
|
||||
q.in_auto = !q.in_auto;
|
||||
|
||||
if (q.in_auto) {
|
||||
btn_automate.classList.add ('pk_act');
|
||||
} else {
|
||||
btn_automate.classList.remove ('pk_act');
|
||||
}
|
||||
};
|
||||
|
||||
q.modal.el_body.appendChild( btn_automate );
|
||||
|
||||
return (btn_automate);
|
||||
};
|
||||
|
||||
function _make_canvas ( q ) {
|
||||
var cc = document.createElement ('canvas');
|
||||
cc.width = 500; cc.height = 200;
|
||||
cc.style.background = '#000';
|
||||
var ctx = cc.getContext('2d');
|
||||
|
||||
q.modal.el_body.appendChild( cc );
|
||||
|
||||
var buff = q.wv.backend.buffer;
|
||||
|
||||
var img = new Image();
|
||||
img.onload = function () {
|
||||
ctx.drawImage (img, 0, 0);
|
||||
};
|
||||
|
||||
var offset; var length;
|
||||
var region = q.wv.regions.list[0];
|
||||
if (region) {
|
||||
offset = (region.start * buff.sampleRate) >> 0;
|
||||
length = (region.end * buff.sampleRate) >> 0;
|
||||
}
|
||||
|
||||
_process ( q, buff );
|
||||
|
||||
img.src = q.app.engine.GetWave (buff, 500, 200, offset, length);
|
||||
|
||||
return ([cc, ctx]);
|
||||
};
|
||||
|
||||
function _compare ( a, b ) {
|
||||
if (a.x > b.x) return 1;
|
||||
return -1;
|
||||
};
|
||||
|
||||
function _process ( q, buffer ) {
|
||||
var getOfflineAudioContext = function (channels, sampleRate, duration) {
|
||||
return new (window.OfflineAudioContext ||
|
||||
window.webkitOfflineAudioContext)(channels, duration, sampleRate);
|
||||
};
|
||||
|
||||
var region = q.wv.regions.list[0];
|
||||
var offs = 0;
|
||||
var durr = buffer.duration;
|
||||
if (region) {
|
||||
offs = region.start;
|
||||
durr = region.end - region.start;
|
||||
}
|
||||
|
||||
var audio_ctx = getOfflineAudioContext (
|
||||
1, // orig_buffer.numberOfChannels,
|
||||
8000,
|
||||
(durr * 8000) >> 0
|
||||
);
|
||||
|
||||
var newbuffer = audio_ctx.createBuffer (1, durr * buffer.sampleRate, buffer.sampleRate);
|
||||
newbuffer.getChannelData ( 0 ).set (
|
||||
buffer.getChannelData ( 0 ).slice ( (offs * buffer.sampleRate) / 4, ((offs + durr) * buffer.sampleRate)/4 )
|
||||
);
|
||||
|
||||
var source = audio_ctx.createBufferSource ();
|
||||
source.buffer = newbuffer;
|
||||
|
||||
//var fx = q.app.engine.GetFX ('Gain', q.GetValue ());
|
||||
//console.log ( fx.filter ( audio_ctx, audio_ctx.destination, source ) );
|
||||
|
||||
source.connect (audio_ctx.destination);
|
||||
source.start (0); //, offs, durr);
|
||||
|
||||
var offline_callback = function( rendered_buffer ) {
|
||||
q.rbuff = rendered_buffer;
|
||||
|
||||
debugger;
|
||||
|
||||
// var img = new Image();
|
||||
// img.src = q.app.engine.GetWave (rendered_buffer, 500, 200);
|
||||
|
||||
q.Render ();
|
||||
|
||||
};
|
||||
var offline_renderer = audio_ctx.startRendering();
|
||||
if (offline_renderer)
|
||||
offline_renderer.then( offline_callback ).catch(function(err) {
|
||||
console.log('Rendering failed: ' + err);
|
||||
});
|
||||
else
|
||||
audio_ctx.oncomplete = function ( e ) {
|
||||
offline_callback ( e.renderedBuffer );
|
||||
};
|
||||
|
||||
// ---------
|
||||
};
|
||||
};
|
||||
|
||||
PKAudioEditor._deps.FxAUT = FXAutomation;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
|
After Width: | Height: | Size: 514 B |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,856 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
|
||||
var StringUtils = {
|
||||
readUTF16String: function(bytes, bigEndian, maxBytes) {
|
||||
var ix = 0;
|
||||
var offset1 = 1, offset2 = 0;
|
||||
maxBytes = Math.min(maxBytes||bytes.length, bytes.length);
|
||||
|
||||
if( bytes[0] == 0xFE && bytes[1] == 0xFF ) {
|
||||
bigEndian = true;
|
||||
ix = 2;
|
||||
} else if( bytes[0] == 0xFF && bytes[1] == 0xFE ) {
|
||||
bigEndian = false;
|
||||
ix = 2;
|
||||
}
|
||||
if( bigEndian ) {
|
||||
offset1 = 0;
|
||||
offset2 = 1;
|
||||
}
|
||||
|
||||
var arr = [];
|
||||
for( var j = 0; ix < maxBytes; j++ ) {
|
||||
var byte1 = bytes[ix+offset1];
|
||||
var byte2 = bytes[ix+offset2];
|
||||
var word1 = (byte1<<8)+byte2;
|
||||
ix += 2;
|
||||
if( word1 == 0x0000 ) {
|
||||
break;
|
||||
} else if( byte1 < 0xD8 || byte1 >= 0xE0 ) {
|
||||
arr[j] = String.fromCharCode(word1);
|
||||
} else {
|
||||
var byte3 = bytes[ix+offset1];
|
||||
var byte4 = bytes[ix+offset2];
|
||||
var word2 = (byte3<<8)+byte4;
|
||||
ix += 2;
|
||||
arr[j] = String.fromCharCode(word1, word2);
|
||||
}
|
||||
}
|
||||
var string = new String(arr.join(""));
|
||||
string.bytesReadCount = ix;
|
||||
return string;
|
||||
},
|
||||
readUTF8String: function(bytes, maxBytes) {
|
||||
var ix = 0;
|
||||
maxBytes = Math.min(maxBytes||bytes.length, bytes.length);
|
||||
|
||||
if( bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF ) {
|
||||
ix = 3;
|
||||
}
|
||||
|
||||
var arr = [];
|
||||
for( var j = 0; ix < maxBytes; j++ ) {
|
||||
var byte1 = bytes[ix++];
|
||||
if( byte1 == 0x00 ) {
|
||||
break;
|
||||
} else if( byte1 < 0x80 ) {
|
||||
arr[j] = String.fromCharCode(byte1);
|
||||
} else if( byte1 >= 0xC2 && byte1 < 0xE0 ) {
|
||||
var byte2 = bytes[ix++];
|
||||
arr[j] = String.fromCharCode(((byte1&0x1F)<<6) + (byte2&0x3F));
|
||||
} else if( byte1 >= 0xE0 && byte1 < 0xF0 ) {
|
||||
var byte2 = bytes[ix++];
|
||||
var byte3 = bytes[ix++];
|
||||
arr[j] = String.fromCharCode(((byte1&0xFF)<<12) + ((byte2&0x3F)<<6) + (byte3&0x3F));
|
||||
} else if( byte1 >= 0xF0 && byte1 < 0xF5) {
|
||||
var byte2 = bytes[ix++];
|
||||
var byte3 = bytes[ix++];
|
||||
var byte4 = bytes[ix++];
|
||||
var codepoint = ((byte1&0x07)<<18) + ((byte2&0x3F)<<12)+ ((byte3&0x3F)<<6) + (byte4&0x3F) - 0x10000;
|
||||
arr[j] = String.fromCharCode(
|
||||
(codepoint>>10) + 0xD800,
|
||||
(codepoint&0x3FF) + 0xDC00
|
||||
);
|
||||
}
|
||||
}
|
||||
var string = new String(arr.join(""));
|
||||
string.bytesReadCount = ix;
|
||||
return string;
|
||||
},
|
||||
readNullTerminatedString: function(bytes, maxBytes) {
|
||||
var arr = [];
|
||||
maxBytes = maxBytes || bytes.length;
|
||||
for ( var i = 0; i < maxBytes; ) {
|
||||
var byte1 = bytes[i++];
|
||||
if( byte1 == 0x00 ) break;
|
||||
arr[i-1] = String.fromCharCode(byte1);
|
||||
}
|
||||
var string = new String(arr.join(""));
|
||||
string.bytesReadCount = i;
|
||||
return string;
|
||||
}
|
||||
};
|
||||
|
||||
var getBytesAt = function(data, iOffset, iLength) {
|
||||
var bytes = new Array(iLength);
|
||||
for( var i = 0; i < iLength; i++ ) {
|
||||
bytes[i] = data.getUint8(iOffset+i);
|
||||
}
|
||||
return bytes;
|
||||
};
|
||||
var getStringWithCharsetAt = function(data, iOffset, iLength, iCharset) {
|
||||
var bytes = getBytesAt(data, iOffset, iLength);
|
||||
var sString;
|
||||
|
||||
switch( iCharset.toLowerCase() ) {
|
||||
case 'utf-16':
|
||||
case 'utf-16le':
|
||||
case 'utf-16be':
|
||||
sString = StringUtils.readUTF16String(bytes, iCharset);
|
||||
break;
|
||||
|
||||
case 'utf-8':
|
||||
sString = StringUtils.readUTF8String(bytes);
|
||||
break;
|
||||
|
||||
default:
|
||||
sString = StringUtils.readNullTerminatedString(bytes);
|
||||
break;
|
||||
}
|
||||
|
||||
return sString;
|
||||
};
|
||||
|
||||
var ID3v2 = {
|
||||
readFrameData: {}
|
||||
};
|
||||
|
||||
ID3v2.frames = {
|
||||
// v2.2
|
||||
"BUF" : "Recommended buffer size",
|
||||
"CNT" : "Play counter",
|
||||
"COM" : "Comments",
|
||||
"CRA" : "Audio encryption",
|
||||
"CRM" : "Encrypted meta frame",
|
||||
"ETC" : "Event timing codes",
|
||||
"EQU" : "Equalization",
|
||||
"GEO" : "General encapsulated object",
|
||||
"IPL" : "Involved people list",
|
||||
"LNK" : "Linked information",
|
||||
"MCI" : "Music CD Identifier",
|
||||
"MLL" : "MPEG location lookup table",
|
||||
"PIC" : "Attached picture",
|
||||
"POP" : "Popularimeter",
|
||||
"REV" : "Reverb",
|
||||
"RVA" : "Relative volume adjustment",
|
||||
"SLT" : "Synchronized lyric/text",
|
||||
"STC" : "Synced tempo codes",
|
||||
"TAL" : "Album/Movie/Show title",
|
||||
"TBP" : "BPM (Beats Per Minute)",
|
||||
"TCM" : "Composer",
|
||||
"TCO" : "Content type",
|
||||
"TCR" : "Copyright message",
|
||||
"TDA" : "Date",
|
||||
"TDY" : "Playlist delay",
|
||||
"TEN" : "Encoded by",
|
||||
"TFT" : "File type",
|
||||
"TIM" : "Time",
|
||||
"TKE" : "Initial key",
|
||||
"TLA" : "Language(s)",
|
||||
"TLE" : "Length",
|
||||
"TMT" : "Media type",
|
||||
"TOA" : "Original artist(s)/performer(s)",
|
||||
"TOF" : "Original filename",
|
||||
"TOL" : "Original Lyricist(s)/text writer(s)",
|
||||
"TOR" : "Original release year",
|
||||
"TOT" : "Original album/Movie/Show title",
|
||||
"TP1" : "Lead artist(s)/Lead performer(s)/Soloist(s)/Performing group",
|
||||
"TP2" : "Band/Orchestra/Accompaniment",
|
||||
"TP3" : "Conductor/Performer refinement",
|
||||
"TP4" : "Interpreted, remixed, or otherwise modified by",
|
||||
"TPA" : "Part of a set",
|
||||
"TPB" : "Publisher",
|
||||
"TRC" : "ISRC (International Standard Recording Code)",
|
||||
"TRD" : "Recording dates",
|
||||
"TRK" : "Track number/Position in set",
|
||||
"TSI" : "Size",
|
||||
"TSS" : "Software/hardware and settings used for encoding",
|
||||
"TT1" : "Content group description",
|
||||
"TT2" : "Title/Songname/Content description",
|
||||
"TT3" : "Subtitle/Description refinement",
|
||||
"TXT" : "Lyricist/text writer",
|
||||
"TXX" : "User defined text information frame",
|
||||
"TYE" : "Year",
|
||||
"UFI" : "Unique file identifier",
|
||||
"ULT" : "Unsychronized lyric/text transcription",
|
||||
"WAF" : "Official audio file webpage",
|
||||
"WAR" : "Official artist/performer webpage",
|
||||
"WAS" : "Official audio source webpage",
|
||||
"WCM" : "Commercial information",
|
||||
"WCP" : "Copyright/Legal information",
|
||||
"WPB" : "Publishers official webpage",
|
||||
"WXX" : "User defined URL link frame",
|
||||
// v2.3
|
||||
"AENC" : "Audio encryption",
|
||||
"APIC" : "Attached picture",
|
||||
"COMM" : "Comments",
|
||||
"COMR" : "Commercial frame",
|
||||
"ENCR" : "Encryption method registration",
|
||||
"EQUA" : "Equalization",
|
||||
"ETCO" : "Event timing codes",
|
||||
"GEOB" : "General encapsulated object",
|
||||
"GRID" : "Group identification registration",
|
||||
"IPLS" : "Involved people list",
|
||||
"LINK" : "Linked information",
|
||||
"MCDI" : "Music CD identifier",
|
||||
"MLLT" : "MPEG location lookup table",
|
||||
"OWNE" : "Ownership frame",
|
||||
"PRIV" : "Private frame",
|
||||
"PCNT" : "Play counter",
|
||||
"POPM" : "Popularimeter",
|
||||
"POSS" : "Position synchronisation frame",
|
||||
"RBUF" : "Recommended buffer size",
|
||||
"RVAD" : "Relative volume adjustment",
|
||||
"RVRB" : "Reverb",
|
||||
"SYLT" : "Synchronized lyric/text",
|
||||
"SYTC" : "Synchronized tempo codes",
|
||||
"TALB" : "Album/Movie/Show title",
|
||||
"TBPM" : "BPM (beats per minute)",
|
||||
"TCOM" : "Composer",
|
||||
"TCON" : "Content type",
|
||||
"TCOP" : "Copyright message",
|
||||
"TDAT" : "Date",
|
||||
"TDLY" : "Playlist delay",
|
||||
"TENC" : "Encoded by",
|
||||
"TEXT" : "Lyricist/Text writer",
|
||||
"TFLT" : "File type",
|
||||
"TIME" : "Time",
|
||||
"TIT1" : "Content group description",
|
||||
"TIT2" : "Title/songname/content description",
|
||||
"TIT3" : "Subtitle/Description refinement",
|
||||
"TKEY" : "Initial key",
|
||||
"TLAN" : "Language(s)",
|
||||
"TLEN" : "Length",
|
||||
"TMED" : "Media type",
|
||||
"TOAL" : "Original album/movie/show title",
|
||||
"TOFN" : "Original filename",
|
||||
"TOLY" : "Original lyricist(s)/text writer(s)",
|
||||
"TOPE" : "Original artist(s)/performer(s)",
|
||||
"TORY" : "Original release year",
|
||||
"TOWN" : "File owner/licensee",
|
||||
"TPE1" : "Lead performer(s)/Soloist(s)",
|
||||
"TPE2" : "Band/orchestra/accompaniment",
|
||||
"TPE3" : "Conductor/performer refinement",
|
||||
"TPE4" : "Interpreted, remixed, or otherwise modified by",
|
||||
"TPOS" : "Part of a set",
|
||||
"TPUB" : "Publisher",
|
||||
"TRCK" : "Track number/Position in set",
|
||||
"TRDA" : "Recording dates",
|
||||
"TRSN" : "Internet radio station name",
|
||||
"TRSO" : "Internet radio station owner",
|
||||
"TSIZ" : "Size",
|
||||
"TSRC" : "ISRC (international standard recording code)",
|
||||
"TSSE" : "Software/Hardware and settings used for encoding",
|
||||
"TYER" : "Year",
|
||||
"TXXX" : "User defined text information frame",
|
||||
"UFID" : "Unique file identifier",
|
||||
"USER" : "Terms of use",
|
||||
"USLT" : "Unsychronized lyric/text transcription",
|
||||
"WCOM" : "Commercial information",
|
||||
"WCOP" : "Copyright/Legal information",
|
||||
"WOAF" : "Official audio file webpage",
|
||||
"WOAR" : "Official artist/performer webpage",
|
||||
"WOAS" : "Official audio source webpage",
|
||||
"WORS" : "Official internet radio station homepage",
|
||||
"WPAY" : "Payment",
|
||||
"WPUB" : "Publishers official webpage",
|
||||
"WXXX" : "User defined URL link frame"
|
||||
};
|
||||
|
||||
var pictureType = [
|
||||
"32x32 pixels 'file icon' (PNG only)",
|
||||
"Other file icon",
|
||||
"Cover (front)",
|
||||
"Cover (back)",
|
||||
"Leaflet page",
|
||||
"Media (e.g. lable side of CD)",
|
||||
"Lead artist/lead performer/soloist",
|
||||
"Artist/performer",
|
||||
"Conductor",
|
||||
"Band/Orchestra",
|
||||
"Composer",
|
||||
"Lyricist/text writer",
|
||||
"Recording Location",
|
||||
"During recording",
|
||||
"During performance",
|
||||
"Movie/video screen capture",
|
||||
"A bright coloured fish",
|
||||
"Illustration",
|
||||
"Band/artist logotype",
|
||||
"Publisher/Studio logotype"
|
||||
];
|
||||
|
||||
var getStringAt = function(data, iOffset, iLength) {
|
||||
var aStr = [];
|
||||
for (var i=iOffset,j=0;i<iOffset+iLength;i++,j++) {
|
||||
aStr[j] = String.fromCharCode(data.getUint8(i));
|
||||
}
|
||||
return aStr.join("");
|
||||
};
|
||||
var getLongAt = function(data, iOffset, bBigEndian) {
|
||||
var iByte1 = data.getUint8(iOffset),
|
||||
iByte2 = data.getUint8(iOffset + 1),
|
||||
iByte3 = data.getUint8(iOffset + 2),
|
||||
iByte4 = data.getUint8(iOffset + 3);
|
||||
|
||||
var iLong = bBigEndian ?
|
||||
(((((iByte1 << 8) + iByte2) << 8) + iByte3) << 8) + iByte4
|
||||
: (((((iByte4 << 8) + iByte3) << 8) + iByte2) << 8) + iByte1;
|
||||
if (iLong < 0) iLong += 4294967296;
|
||||
return iLong;
|
||||
};
|
||||
var getSLongAt = function(data, iOffset, bBigEndian) {
|
||||
var iULong = getLongAt(data, iOffset, bBigEndian);
|
||||
if (iULong > 2147483647)
|
||||
return iULong - 4294967296;
|
||||
else
|
||||
return iULong;
|
||||
};
|
||||
var getShortAt = function(data, iOffset, bBigEndian) {
|
||||
var iShort = bBigEndian ?
|
||||
(data.getUint8(iOffset) << 8) + data.getUint8(iOffset + 1)
|
||||
: (data.getUint8(iOffset + 1) << 8) + data.getUint8(iOffset);
|
||||
if (iShort < 0) iShort += 65536;
|
||||
return iShort;
|
||||
};
|
||||
var getInteger24At = function(data, iOffset, bBigEndian) {
|
||||
var iByte1 = data.getUint8(iOffset),
|
||||
iByte2 = data.getUint8(iOffset + 1),
|
||||
iByte3 = data.getUint8(iOffset + 2);
|
||||
|
||||
var iInteger = bBigEndian ?
|
||||
((((iByte1 << 8) + iByte2) << 8) + iByte3)
|
||||
: ((((iByte3 << 8) + iByte2) << 8) + iByte1);
|
||||
if (iInteger < 0) iInteger += 16777216;
|
||||
return iInteger;
|
||||
};
|
||||
var isBitSetAt = function ( dataview, offset, bit ) {
|
||||
var ibyte = dataview.getUint8(offset);
|
||||
return (ibyte & (1 << bit)) != 0;
|
||||
};
|
||||
var readSynchsafeInteger32At = function (offset, data) {
|
||||
var size1 = data.getUint8(offset);
|
||||
var size2 = data.getUint8(offset+1);
|
||||
var size3 = data.getUint8(offset+2);
|
||||
var size4 = data.getUint8(offset+3);
|
||||
// 0x7f = 0b01111111
|
||||
var size = size4 & 0x7f
|
||||
| ((size3 & 0x7f) << 7)
|
||||
| ((size2 & 0x7f) << 14)
|
||||
| ((size1 & 0x7f) << 21);
|
||||
|
||||
return size;
|
||||
};
|
||||
var readFrameFlags = function(data, offset) {
|
||||
var flags =
|
||||
{
|
||||
message:
|
||||
{
|
||||
tag_alter_preservation : isBitSetAt(data, offset, 6),
|
||||
file_alter_preservation : isBitSetAt(data, offset, 5),
|
||||
read_only : isBitSetAt(data, offset, 4)
|
||||
},
|
||||
format:
|
||||
{
|
||||
grouping_identity : isBitSetAt(data, offset+1, 7),
|
||||
compression : isBitSetAt(data, offset+1, 3),
|
||||
encription : isBitSetAt(data, offset+1, 2),
|
||||
unsynchronisation : isBitSetAt(data, offset+1, 1),
|
||||
data_length_indicator : isBitSetAt(data, offset+1, 0)
|
||||
}
|
||||
};
|
||||
|
||||
return flags;
|
||||
};
|
||||
var _shortcuts = {
|
||||
"title" : ["TIT2", "TT2"],
|
||||
"artist" : ["TPE1", "TP1"],
|
||||
"album" : ["TALB", "TAL"],
|
||||
"year" : ["TYER", "TYE"],
|
||||
"comment" : ["COMM", "COM"],
|
||||
"track" : ["TRCK", "TRK"],
|
||||
"genre" : ["TCON", "TCO"],
|
||||
"picture" : ["APIC", "PIC"],
|
||||
"lyrics" : ["USLT", "ULT"]
|
||||
};
|
||||
var _defaultShortcuts = ["title", "artist", "album", "track"];
|
||||
|
||||
var getTagsFromShortcuts = function(shortcuts) {
|
||||
var tags = [];
|
||||
for( var i = 0, shortcut; shortcut = shortcuts[i]; i++ ) {
|
||||
tags = tags.concat(_shortcuts[shortcut]||[shortcut]);
|
||||
}
|
||||
return tags;
|
||||
};
|
||||
var getFrameData = function( frames, ids ) {
|
||||
if( typeof ids == 'string' ) { ids = [ids]; }
|
||||
|
||||
for( var i = 0, id; id = ids[i]; i++ ) {
|
||||
if( id in frames ) { return frames[id].data; }
|
||||
}
|
||||
};
|
||||
var readFrames = function (offset, end, data, id3header, tags) {
|
||||
var frames = {};
|
||||
var frameDataSize;
|
||||
var major = id3header["major"];
|
||||
|
||||
tags = getTagsFromShortcuts(tags || _defaultShortcuts);
|
||||
|
||||
while( offset < end ) {
|
||||
var readFrameFunc = null;
|
||||
var frameData = data;
|
||||
var frameDataOffset = offset;
|
||||
var flags = null;
|
||||
|
||||
switch( major ) {
|
||||
case 2:
|
||||
var frameID = getStringAt(frameData, frameDataOffset, 3);
|
||||
var frameSize = getInteger24At(frameData, frameDataOffset+3, true);
|
||||
var frameHeaderSize = 6;
|
||||
break;
|
||||
|
||||
case 3:
|
||||
var frameID = getStringAt(frameData, frameDataOffset, 4);
|
||||
var frameSize = getLongAt(frameData, frameDataOffset+4, true);
|
||||
var frameHeaderSize = 10;
|
||||
break;
|
||||
|
||||
case 4:
|
||||
var frameID = getStringAt(frameData, frameDataOffset, 4);
|
||||
var frameSize = readSynchsafeInteger32At(frameDataOffset+4, frameData);
|
||||
var frameHeaderSize = 10;
|
||||
break;
|
||||
}
|
||||
// if last frame GTFO
|
||||
if( frameID == "" ) { break; }
|
||||
|
||||
// advance data offset to the next frame data
|
||||
offset += frameHeaderSize + frameSize;
|
||||
// skip unwanted tags
|
||||
if( tags.indexOf( frameID ) < 0 ) { continue; }
|
||||
|
||||
// read frame message and format flags
|
||||
if( major > 2 )
|
||||
{
|
||||
flags = readFrameFlags(frameData, frameDataOffset+8);
|
||||
}
|
||||
|
||||
frameDataOffset += frameHeaderSize;
|
||||
|
||||
// the first 4 bytes are the real data size
|
||||
// (after unsynchronisation && encryption)
|
||||
if( flags && flags.format.data_length_indicator )
|
||||
{
|
||||
frameDataSize = readSynchsafeInteger32At(frameDataOffset, frameData);
|
||||
frameDataOffset += 4;
|
||||
frameSize -= 4;
|
||||
}
|
||||
|
||||
// TODO: support unsynchronisation
|
||||
if( flags && flags.format.unsynchronisation )
|
||||
{
|
||||
//frameData = removeUnsynchronisation(frameData, frameSize);
|
||||
continue;
|
||||
}
|
||||
|
||||
// find frame parsing function
|
||||
|
||||
if( frameID in ID3v2.readFrameData ) {
|
||||
readFrameFunc = ID3v2.readFrameData[frameID];
|
||||
} else if( frameID[0] == "T" ) {
|
||||
readFrameFunc = ID3v2.readFrameData["T*"];
|
||||
}
|
||||
|
||||
var parsedData = readFrameFunc ? readFrameFunc(frameDataOffset, frameSize, frameData, flags) : undefined;
|
||||
var desc = frameID in ID3v2.frames ? ID3v2.frames[frameID] : 'Unknown';
|
||||
|
||||
var frame = {
|
||||
id : frameID,
|
||||
size : frameSize,
|
||||
description : desc,
|
||||
data : parsedData
|
||||
};
|
||||
|
||||
if( frameID in frames ) {
|
||||
if( frames[frameID].id ) {
|
||||
frames[frameID] = [frames[frameID]];
|
||||
}
|
||||
frames[frameID].push(frame);
|
||||
} else {
|
||||
frames[frameID] = frame;
|
||||
}
|
||||
}
|
||||
|
||||
return frames;
|
||||
};
|
||||
|
||||
function getTextEncoding( bite ) {
|
||||
var charset;
|
||||
switch( bite )
|
||||
{
|
||||
case 0x00:
|
||||
charset = 'iso-8859-1';
|
||||
break;
|
||||
|
||||
case 0x01:
|
||||
charset = 'utf-16';
|
||||
break;
|
||||
|
||||
case 0x02:
|
||||
charset = 'utf-16be';
|
||||
break;
|
||||
|
||||
case 0x03:
|
||||
charset = 'utf-8';
|
||||
break;
|
||||
}
|
||||
|
||||
return charset;
|
||||
}
|
||||
|
||||
function getTime( duration )
|
||||
{
|
||||
var duration = duration/1000,
|
||||
seconds = Math.floor( duration ) % 60,
|
||||
minutes = Math.floor( duration/60 ) % 60,
|
||||
hours = Math.floor( duration/3600 );
|
||||
|
||||
return {
|
||||
seconds : seconds,
|
||||
minutes : minutes,
|
||||
hours : hours
|
||||
};
|
||||
}
|
||||
|
||||
function formatTime( time )
|
||||
{
|
||||
var seconds = time.seconds < 10 ? '0'+time.seconds : time.seconds;
|
||||
var minutes = (time.hours > 0 && time.minutes < 10) ? '0'+time.minutes : time.minutes;
|
||||
|
||||
return (time.hours>0?time.hours+':':'') + minutes + ':' + seconds;
|
||||
}
|
||||
|
||||
ID3v2.readFrameData['APIC'] = function readPictureFrame(offset, length, data, flags, v) {
|
||||
v = v || '3';
|
||||
|
||||
var start = offset;
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
switch( v ) {
|
||||
case '2':
|
||||
var format = getStringAt(data, offset+1, 3);
|
||||
offset += 4;
|
||||
break;
|
||||
|
||||
case '3':
|
||||
case '4':
|
||||
var format = getStringWithCharsetAt(data, offset+1, length - (offset-start), '');
|
||||
offset += 1 + format.bytesReadCount;
|
||||
break;
|
||||
}
|
||||
var bite = data.getUint8(offset, 1);
|
||||
var type = pictureType[bite];
|
||||
var desc = getStringWithCharsetAt(data, offset+1, length - (offset-start), charset);
|
||||
|
||||
offset += 1 + desc.bytesReadCount;
|
||||
|
||||
return {
|
||||
"format" : format.toString(),
|
||||
"type" : type,
|
||||
"description" : desc.toString(),
|
||||
"data" : getBytesAt(data, offset, (start+length) - offset)
|
||||
};
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['COMM'] = function readCommentsFrame(offset, length, data) {
|
||||
var start = offset;
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
var language = getStringAt(data, offset+1, 3 );
|
||||
var shortdesc = getStringWithCharsetAt(data, offset+4, length-4, charset);
|
||||
|
||||
offset += 4 + shortdesc.bytesReadCount;
|
||||
var text = getStringWithCharsetAt(data, offset, (start+length) - offset, charset );
|
||||
|
||||
return {
|
||||
language : language,
|
||||
short_description : shortdesc.toString(),
|
||||
text : text.toString()
|
||||
};
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['COM'] = ID3v2.readFrameData['COMM'];
|
||||
|
||||
ID3v2.readFrameData['PIC'] = function(offset, length, data, flags) {
|
||||
return ID3v2.readFrameData['APIC'](offset, length, data, flags, '2');
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['PCNT'] = function readCounterFrame(offset, length, data) {
|
||||
// FIXME: implement the rest of the spec
|
||||
return data.getInteger32At(offset);
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['CNT'] = ID3v2.readFrameData['PCNT'];
|
||||
|
||||
ID3v2.readFrameData['T*'] = function readTextFrame(offset, length, data) {
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
|
||||
return getStringWithCharsetAt(data, offset+1, length-1, charset).toString();
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['TCON'] = function readGenreFrame(offset, length, data) {
|
||||
var text = ID3v2.readFrameData['T*'].apply( this, arguments );
|
||||
return text.replace(/^\(\d+\)/, '');
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['TCO'] = ID3v2.readFrameData['TCON'];
|
||||
|
||||
//ID3v2.readFrameData['TLEN'] = function readLengthFrame(offset, length, data) {
|
||||
// var text = ID3v2.readFrameData['T*'].apply( this, arguments );
|
||||
//
|
||||
// return {
|
||||
// text : text,
|
||||
// parsed : formatTime( getTime(parseInt(text)) )
|
||||
// };
|
||||
//};
|
||||
|
||||
ID3v2.readFrameData['USLT'] = function readLyricsFrame(offset, length, data) {
|
||||
var start = offset;
|
||||
var charset = getTextEncoding( data.getUint8(offset) );
|
||||
var language = getStringAt(data, offset+1, 3 );
|
||||
var descriptor = getStringWithCharsetAt(data, offset+4, length-4, charset );
|
||||
|
||||
offset += 4 + descriptor.bytesReadCount;
|
||||
var lyrics = getStringWithCharsetAt(data, offset, (start+length) - offset, charset );
|
||||
|
||||
return {
|
||||
language : language,
|
||||
descriptor : descriptor.toString(),
|
||||
lyrics : lyrics.toString()
|
||||
};
|
||||
};
|
||||
|
||||
ID3v2.readFrameData['ULT'] = ID3v2.readFrameData['USLT'];
|
||||
|
||||
|
||||
ID3v2.ReadTags = function ( arraybuffer ) {
|
||||
var data = new DataView ( arraybuffer );
|
||||
var offset = 0;
|
||||
|
||||
|
||||
var major = data.getUint8(offset+3);
|
||||
if( major > 4 ) { return {version: '>2.4'}; }
|
||||
var revision = data.getUint8(offset+4);
|
||||
var unsynch = isBitSetAt(data, offset+5, 7);
|
||||
var xheader = isBitSetAt(data, offset+5, 6);
|
||||
var xindicator = isBitSetAt(data, offset+5, 5);
|
||||
var size = readSynchsafeInteger32At(offset+6, data);
|
||||
offset += 10;
|
||||
|
||||
if( xheader ) {
|
||||
var xheadersize = data.getInt32( offset, true ); //data.getLongAt(offset, true);
|
||||
// The 'Extended header size', currently 6 or 10 bytes, excludes itself.
|
||||
offset += xheadersize + 4;
|
||||
}
|
||||
|
||||
var id3 = {
|
||||
"version" : '2.' + major + '.' + revision,
|
||||
"major" : major,
|
||||
"revision" : revision,
|
||||
"flags" : {
|
||||
"unsynchronisation" : unsynch,
|
||||
"extended_header" : xheader,
|
||||
"experimental_indicator" : xindicator
|
||||
},
|
||||
"size" : size
|
||||
};
|
||||
|
||||
var frames = unsynch ? {} : readFrames(offset, size-10, data, id3);
|
||||
// create shortcuts for most common data
|
||||
for( var name in _shortcuts ) if(_shortcuts.hasOwnProperty(name)) {
|
||||
var data = getFrameData( frames, _shortcuts[name] );
|
||||
if( data ) id3[name] = data;
|
||||
}
|
||||
|
||||
for( var frame in frames ) {
|
||||
if( frames.hasOwnProperty(frame) ) {
|
||||
id3[frame] = frames[frame];
|
||||
}
|
||||
}
|
||||
|
||||
return id3;
|
||||
};
|
||||
|
||||
w.ID3v2 = ID3v2;
|
||||
|
||||
|
||||
/// -------
|
||||
|
||||
var ID4 = {};
|
||||
|
||||
ID4.types = {
|
||||
'0' : 'uint8',
|
||||
'1' : 'text',
|
||||
'13' : 'jpeg',
|
||||
'14' : 'png',
|
||||
'21' : 'uint8'
|
||||
};
|
||||
ID4.atom = {
|
||||
'©alb': ['album'],
|
||||
'©art': ['artist'],
|
||||
'©ART': ['artist'],
|
||||
'aART': ['artist'],
|
||||
'©day': ['year'],
|
||||
'©nam': ['title'],
|
||||
'©gen': ['genre'],
|
||||
'trkn': ['track'],
|
||||
'©wrt': ['composer'],
|
||||
'©too': ['encoder'],
|
||||
'cprt': ['copyright'],
|
||||
'covr': ['picture'],
|
||||
'©grp': ['grouping'],
|
||||
'keyw': ['keyword'],
|
||||
'©lyr': ['lyrics'],
|
||||
'©cmt': ['comment'],
|
||||
'tmpo': ['tempo'],
|
||||
'cpil': ['compilation'],
|
||||
'disk': ['disc']
|
||||
};
|
||||
|
||||
ID4.loadData = function(arraybuffer, callback) {
|
||||
var data = new DataView ( arraybuffer );
|
||||
// load the header of the first block
|
||||
loadAtom(data, 0, data.byteLength, callback);
|
||||
};
|
||||
|
||||
/**
|
||||
* Make sure that the [offset, offset+7] bytes (the block header) are
|
||||
* already loaded before calling this function.
|
||||
*/
|
||||
function loadAtom(data, offset, length, callback) {
|
||||
// 8 is the size of the atomSize and atomName fields.
|
||||
// When reading the current block we always read 8 more bytes in order
|
||||
// to also read the header of the next block.
|
||||
var atomSize = getLongAt(data, offset, true);
|
||||
if (atomSize == 0) return callback();
|
||||
var atomName = getStringAt(data, offset + 4, 4);
|
||||
|
||||
// Container atoms
|
||||
if (['moov', 'udta', 'meta', 'ilst'].indexOf(atomName) > -1)
|
||||
{
|
||||
if (atomName == 'meta') offset += 4; // next_item_id (uint32)
|
||||
// data.loadRange([offset+8, offset+8 + 8], function() {
|
||||
loadAtom(data, offset + 8, atomSize - 8, callback);
|
||||
// });
|
||||
} else {
|
||||
// Value atoms
|
||||
var readAtom = atomName in ID4.atom;
|
||||
// data.loadRange([offset+(readAtom?0:atomSize), offset+atomSize + 8], function() {
|
||||
loadAtom(data, offset+atomSize, length, callback);
|
||||
// });
|
||||
}
|
||||
};
|
||||
|
||||
ID4.ReadTags = function(arraybuffer) {
|
||||
var data = new DataView ( arraybuffer );
|
||||
var tag = {};
|
||||
readAtom(tag, data, 0, data.byteLength);
|
||||
return tag;
|
||||
};
|
||||
|
||||
function readAtom(tag, data, offset, length, indent)
|
||||
{
|
||||
// debugger;
|
||||
|
||||
indent = indent === undefined ? "" : indent + " ";
|
||||
var seek = offset;
|
||||
while (seek < offset + length)
|
||||
{
|
||||
var atomSize = data.getInt32(seek); // getLongAt(data, seek, true);
|
||||
if (atomSize == 0) return;
|
||||
var atomName = getStringAt(data, seek + 4, 4);
|
||||
// Container atoms
|
||||
if (atomName === 'meta')
|
||||
{
|
||||
seek += 4; // next_item_id (uint32)
|
||||
readAtom(tag, data, seek + 8, atomSize - 8, indent);
|
||||
return;
|
||||
}
|
||||
if (atomName === 'moov' || atomName === 'udta' || atomName === 'ilst' ) // ['moov', 'udta', 'meta', 'ilst'].indexOf(atomName) > -1)
|
||||
{
|
||||
readAtom(tag, data, seek + 8, atomSize - 8, indent);
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
if (['moov', 'udta', 'meta', 'ilst'].indexOf(atomName) > -1)
|
||||
{
|
||||
if (atomName === 'meta') seek += 4; // next_item_id (uint32)
|
||||
readAtom(tag, data, seek + 8, atomSize - 8, indent);
|
||||
return;
|
||||
}
|
||||
*/
|
||||
|
||||
// Value atoms
|
||||
if (ID4.atom[atomName])
|
||||
{
|
||||
var klass = getInteger24At(data, seek + 16 + 1, true);
|
||||
var atom = ID4.atom[atomName];
|
||||
var type = ID4.types[klass];
|
||||
if (atomName === 'trkn')
|
||||
{
|
||||
tag[atom[0]] = data.getUint8(seek + 16 + 11);
|
||||
tag['count'] = data.getUint8(seek + 16 + 13);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 16: name + size + "data" + size (4 bytes each)
|
||||
// 4: atom version (1 byte) + atom flags (3 bytes)
|
||||
// 4: NULL (usually locale indicator)
|
||||
var dataStart = seek + 16 + 4 + 4;
|
||||
var dataEnd = atomSize - 16 - 4 - 4;
|
||||
var atomData;
|
||||
switch( type ) {
|
||||
case 'text':
|
||||
atomData = getStringWithCharsetAt(data, dataStart, dataEnd, "UTF-8");
|
||||
break;
|
||||
|
||||
case 'uint8':
|
||||
atomData = getShortAt(data, dataStart);
|
||||
break;
|
||||
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
atomData = {
|
||||
format : "image/" + type,
|
||||
data : getBytesAt(data, dataStart, dataEnd)
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
if (atom[0] === "comment") {
|
||||
tag[atom[0]] = {
|
||||
"text": atomData
|
||||
};
|
||||
} else {
|
||||
tag[atom[0]] = atomData;
|
||||
}
|
||||
}
|
||||
}
|
||||
seek += atomSize;
|
||||
}
|
||||
}
|
||||
|
||||
w.ID4 = ID4;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,66 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" manifest="audiomass.appcache">
|
||||
<head>
|
||||
<title>AudioMass - Audio Editor</title>
|
||||
<link href="ico.png" rel="shortcut icon">
|
||||
<meta charset="utf-8" />
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0"/>
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link rel="apple-touch-icon" href="https://audiomass.co/icon-app.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="AudioMass">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:site" content="@pkalogiros">
|
||||
<meta name="twitter:creator" content="@pkalogiros">
|
||||
<meta name="twitter:title" content="AudioMass - Audio Editor">
|
||||
<meta name="twitter:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="twitter:image" content="https://audiomass.co/icon.jpg">
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="dist/wavesurfer.js"></script>
|
||||
<script src="dist/plugin/wavesurfer.regions.js"></script>
|
||||
<script src="oneup.js"></script>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="keys.js"></script>
|
||||
<script src="contextmenu.js"></script>
|
||||
<script src="ui-fx.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="modal.js"></script>
|
||||
<script src="state.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="actions.js"></script>
|
||||
<script src="drag.js"></script>
|
||||
<script src="recorder.js"></script>
|
||||
|
||||
<script src="welcome.js"></script>
|
||||
<script src="fx-pg-eq.js"></script>
|
||||
<script src="fx-auto.js"></script>
|
||||
<script src="local.js"></script>
|
||||
<script src="id3.js"></script>
|
||||
<script src="lzma.js"></script>
|
||||
|
||||
|
||||
<!--
|
||||
<script src="multitrack.js"></script>
|
||||
<script src="multitrack-ui.js"></script>
|
||||
-->
|
||||
<script>
|
||||
var editor = PKAudioEditor.init ('app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,72 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>AudioMass - Audio Editor</title>
|
||||
<link href="ico.png" rel="shortcut icon">
|
||||
<meta charset="utf-8" />
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
|
||||
|
||||
<meta name="description" content="AudioMass is a free full-featured web-based audio & waveform editing tool"/>
|
||||
<meta property="og:image" content="https://audiomass.co/icon.jpg"/>
|
||||
<meta property="og:title" content="AudioMass">
|
||||
<meta property="og:url" content="https://audiomass.co/">
|
||||
<meta property="og:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="keywords" content="AudioMass, WebAudio, WaveForm, audio editing, free audio editing, audio tool, waveform editor, sound editor, open source">
|
||||
<link rel="apple-touch-icon" href="https://audiomass.co/icon-app.png">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="AudioMass">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:site" content="@pkalogiros">
|
||||
<meta name="twitter:creator" content="@pkalogiros">
|
||||
<meta name="twitter:title" content="AudioMass - Audio Editor">
|
||||
<meta name="twitter:description" content="AudioMass is a free full-featured web-based audio & waveform editing tool">
|
||||
<meta name="twitter:image" content="https://audiomass.co/icon.jpg">
|
||||
|
||||
|
||||
<link rel="stylesheet" type="text/css" href="main.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
<script src="dist/wavesurfer.js"></script>
|
||||
<script src="dist/plugin/wavesurfer.regions.js"></script>
|
||||
<script src="oneup.js"></script>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="keys.js"></script>
|
||||
<script src="contextmenu.js"></script>
|
||||
<script src="ui-fx.js"></script>
|
||||
<script src="ui.js"></script>
|
||||
<script src="modal.js"></script>
|
||||
<script src="state.js"></script>
|
||||
<script src="engine.js"></script>
|
||||
<script src="actions.js"></script>
|
||||
<script src="drag.js"></script>
|
||||
<script src="recorder.js"></script>
|
||||
|
||||
<script src="welcome.js"></script>
|
||||
<script src="fx-pg-eq.js"></script>
|
||||
<script src="fx-auto.js"></script>
|
||||
<script src="local.js"></script>
|
||||
<script src="id3.js"></script>
|
||||
<script src="lzma.js"></script>
|
||||
<!--
|
||||
<script src="multitrack.js"></script>
|
||||
<script src="multitrack-ui.js"></script>
|
||||
-->
|
||||
<script>
|
||||
var editor = PKAudioEditor.init ('app');
|
||||
if ('serviceWorker' in navigator) {
|
||||
try {
|
||||
// navigator.serviceWorker.register( 'sw.js' );
|
||||
} catch ( error ) {}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,89 @@
|
||||
(function( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
function KeyHandler () {
|
||||
var q = this;
|
||||
|
||||
q.keyMap = {}; // holds a map of all the active keys
|
||||
q.callbacks = {}; // callbacks for when a key combintation becomes active
|
||||
q.singleCallbacks = {}; // callbacks to the 'keypress' event - not required
|
||||
|
||||
q.addCallback = function (callback_name, callback_function, keys) {
|
||||
q.callbacks[ callback_name ] = {
|
||||
keys : keys,
|
||||
callback : callback_function
|
||||
};
|
||||
};
|
||||
q.addSingleCallback = function (callback_name, callback_function, key) {
|
||||
q.singleCallbacks[ callback_name ] = {
|
||||
key : key,
|
||||
callback : callback_function
|
||||
};
|
||||
};
|
||||
q.removeCallback = function ( callback_name ) {
|
||||
q.callbacks[ callback_name ] = null;
|
||||
};
|
||||
|
||||
d.addEventListener ('keydown', function ( e ) {
|
||||
var keyCode = e.keyCode;
|
||||
|
||||
q.keyDown (keyCode, e);
|
||||
});
|
||||
|
||||
q.keyDown = function (keyCode, e ) {
|
||||
q.keyMap[keyCode] = 1;
|
||||
|
||||
for (var key in q.callbacks) {
|
||||
var group = q.callbacks[key];
|
||||
if (!group) continue;
|
||||
|
||||
var l = group.keys.length;
|
||||
var all_ok = true;
|
||||
while (l-- > 0) {
|
||||
if (!q.keyMap[group.keys[l]])
|
||||
{
|
||||
all_ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
all_ok && group.callback && group.callback ( keyCode, q.keyMap, e );
|
||||
}
|
||||
};
|
||||
|
||||
q.keyUp = function ( keyCode ) {
|
||||
q.keyMap[keyCode] = 0;
|
||||
};
|
||||
|
||||
q.keyPress = function ( keyCode, e ) {
|
||||
for (var key in q.singleCallbacks) {
|
||||
var group = q.singleCallbacks[key];
|
||||
if (!group) continue;
|
||||
|
||||
if (group.key === keyCode)
|
||||
group.callback && group.callback ( e );
|
||||
}
|
||||
};
|
||||
|
||||
d.addEventListener ('keyup', function ( e ) {
|
||||
var keyCode = e.keyCode;
|
||||
q.keyUp (keyCode);
|
||||
});
|
||||
|
||||
d.addEventListener ('keypress', function ( e ) {
|
||||
var keyCode = e.keyCode;
|
||||
|
||||
q.keyPress (keyCode, e);
|
||||
});
|
||||
|
||||
w.addEventListener ('blur', function ( e ) {
|
||||
q.keyMap = {};
|
||||
}, false);
|
||||
d.addEventListener ('contextmenu', function( e ) {
|
||||
e.preventDefault();
|
||||
}, false);
|
||||
};
|
||||
|
||||
PKAE._deps.keyhandler = KeyHandler;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,262 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var db;
|
||||
var db_name = 'audiomass';
|
||||
var db_version = 1;
|
||||
var db_ready = false;
|
||||
|
||||
var compressors = {
|
||||
'l4z' : {
|
||||
ready: false,
|
||||
loading: false,
|
||||
compress: null,
|
||||
decompress: null,
|
||||
init : function ( callback ) {
|
||||
|
||||
var q = this;
|
||||
q.loading = true;
|
||||
|
||||
var lz4BlockWASM;
|
||||
|
||||
lz4BlockCodec.createInstance('wasm').then(instance => {
|
||||
lz4BlockWASM = instance;
|
||||
|
||||
q.ready = true;
|
||||
q.loading = false;
|
||||
q.compress = function( input, offset ) {
|
||||
if (!lz4BlockWASM) {
|
||||
if (input instanceof ArrayBuffer)
|
||||
return new Uint8Array(input);
|
||||
else
|
||||
return input;
|
||||
}
|
||||
|
||||
return lz4BlockWASM.encodeBlock(input, 0);
|
||||
};
|
||||
q.decompress = function( input, offset, size ) {
|
||||
if (!lz4BlockWASM) {
|
||||
if (input instanceof ArrayBuffer)
|
||||
return new Uint8Array(input);
|
||||
else
|
||||
return input;
|
||||
}
|
||||
|
||||
return lz4BlockWASM.decodeBlock(input, 0, size);
|
||||
};
|
||||
|
||||
callback && callback ();
|
||||
});
|
||||
// ---
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var compression = 'l4z';
|
||||
|
||||
function SaveLocal ( app ) {
|
||||
var q = this;
|
||||
q.on = false;
|
||||
|
||||
this.Init = function ( callback ) {
|
||||
if (q.on) {
|
||||
callback && callback ();
|
||||
return ;
|
||||
}
|
||||
|
||||
if (!window.indexedDB) {
|
||||
callback && callback ('err');
|
||||
return ;
|
||||
}
|
||||
|
||||
var request = indexedDB.open (db_name, db_version);
|
||||
|
||||
request.onerror = function(e) {
|
||||
callback && callback ('err');
|
||||
// console.error('Unable to open database.');
|
||||
};
|
||||
|
||||
request.onupgradeneeded = function(e) {
|
||||
var db = e.target.result;
|
||||
db.createObjectStore('sessions', {keyPath:'id'});
|
||||
};
|
||||
|
||||
request.onsuccess = function(e) {
|
||||
db = e.target.result;
|
||||
|
||||
db.onerror = function( e ) {
|
||||
console.log( e );
|
||||
};
|
||||
|
||||
setTimeout(function() {
|
||||
db_ready = true;
|
||||
q.on = true;
|
||||
|
||||
callback && callback ();
|
||||
app.fireEvent ('DidOpenDB', q);
|
||||
},120);
|
||||
};
|
||||
};
|
||||
|
||||
this.SaveSession = function ( buffer, id, name ) {
|
||||
var q = this;
|
||||
|
||||
var comp = compressors[ compression ];
|
||||
if (!comp.loading && !comp.ready) {
|
||||
comp.init (function() {
|
||||
q.SaveSession (buffer, id, name);
|
||||
});
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
var chans = buffer.numberOfChannels;
|
||||
var arr_buffs = [];
|
||||
var arr_buffs2 = [];
|
||||
var arr;
|
||||
var tmp;
|
||||
|
||||
var sample_rate = buffer.sampleRate;
|
||||
|
||||
for (var i = 0; i < chans; ++i) {
|
||||
arr = buffer.getChannelData ( i );
|
||||
|
||||
arr_buffs2.push (arr.buffer.byteLength);
|
||||
tmp = comp.compress ( arr.buffer, 0);
|
||||
arr_buffs.push ( tmp.buffer.slice (tmp.byteOffset, tmp.byteLength + tmp.byteOffset));
|
||||
}
|
||||
|
||||
tmp = null;
|
||||
|
||||
var ob = {
|
||||
id : id,
|
||||
name: name,
|
||||
created: new Date().getTime(),
|
||||
data: arr_buffs,
|
||||
data2: arr_buffs2,
|
||||
durr: buffer.duration.toFixed(3)/1,
|
||||
chans: chans,
|
||||
comp: compression,
|
||||
thumb: PKAudioEditor.engine.GetWave (buffer),
|
||||
samplerate: sample_rate
|
||||
};
|
||||
|
||||
var trans = db.transaction(['sessions'], 'readwrite');
|
||||
var addReq = trans.objectStore('sessions').add(ob);
|
||||
|
||||
addReq.onerror = function(e) {
|
||||
app.fireEvent ('ErrorDB', e);
|
||||
|
||||
console.log('error storing data');
|
||||
console.error(e);
|
||||
};
|
||||
|
||||
trans.oncomplete = function ( e ) {
|
||||
app.fireEvent ('DidStoreDB', ob, e );
|
||||
// console.log( 'data stored', id, e );
|
||||
};
|
||||
};
|
||||
|
||||
this.GetSession = function ( id, callback ) {
|
||||
var trans = db.transaction(['sessions'], 'readonly');
|
||||
//hard coded id
|
||||
var req = trans.objectStore('sessions').get(id);
|
||||
|
||||
req.onsuccess = function(e) {
|
||||
// console.log( e.target.result );
|
||||
|
||||
|
||||
var record = e.target.result;
|
||||
|
||||
if (record && record.comp)
|
||||
{
|
||||
var comp = compressors[ compression ];
|
||||
if (!comp.loading && !comp.ready) {
|
||||
comp.init (function() {
|
||||
|
||||
var data_arr = [];
|
||||
var tmp = null;
|
||||
|
||||
for (var i = 0; i < record.data.length; ++i) {
|
||||
tmp = comp.decompress (record.data[i], 0, record.data2[i]);
|
||||
data_arr.push (
|
||||
tmp.buffer.slice (tmp.byteOffset, tmp.byteLength + tmp.byteOffset)
|
||||
);
|
||||
}
|
||||
|
||||
tmp = null;
|
||||
record.data = data_arr;
|
||||
|
||||
callback && callback ( record );
|
||||
});
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
var data_arr = [];
|
||||
var tmp = null;
|
||||
|
||||
for (var i = 0; i < record.data.length; ++i) {
|
||||
tmp = comp.decompress (record.data[i], 0, record.data2[i]);
|
||||
data_arr.push (
|
||||
tmp.buffer.slice (tmp.byteOffset, tmp.byteLength + tmp.byteOffset)
|
||||
);
|
||||
}
|
||||
|
||||
tmp = null;
|
||||
record.data = data_arr;
|
||||
}
|
||||
|
||||
callback && callback ( record );
|
||||
};
|
||||
};
|
||||
|
||||
this.DelSession = function ( id, callback ) {
|
||||
var trans = db.transaction(['sessions'], 'readwrite');
|
||||
|
||||
var req = trans.objectStore('sessions').delete (id);
|
||||
req.onsuccess = function (e) {
|
||||
callback && callback ( id );
|
||||
};
|
||||
};
|
||||
|
||||
this.ListSessions = function ( callback ) {
|
||||
var trans = db.transaction(['sessions'], 'readonly');
|
||||
var object_store = trans.objectStore('sessions');
|
||||
var req = object_store.openCursor();
|
||||
var ret = [];
|
||||
|
||||
req.onerror = function(event) {
|
||||
console.err("error fetching data");
|
||||
};
|
||||
req.onsuccess = function(event) {
|
||||
var cursor = event.target.result;
|
||||
if (cursor) {
|
||||
var key = cursor.primaryKey;
|
||||
var value = cursor.value;
|
||||
|
||||
ret.push (value);
|
||||
cursor.continue();
|
||||
}
|
||||
else {
|
||||
var rr = ret.sort(function compare( a, b ) {
|
||||
if ( a.created > b.created ){
|
||||
return -1;
|
||||
}
|
||||
if ( a.created < b.created ){
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
callback && callback (rr);
|
||||
// no more results
|
||||
}
|
||||
};
|
||||
};
|
||||
// ---
|
||||
};
|
||||
|
||||
PKAudioEditor._deps.fls = SaveLocal;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,194 @@
|
||||
/*******************************************************************************
|
||||
|
||||
lz4-block-codec-wasm.js
|
||||
A javascript wrapper around a WebAssembly implementation of
|
||||
LZ4 block format codec.
|
||||
Copyright (C) 2018 Raymond Hill
|
||||
|
||||
BSD-2-Clause License (http://www.opensource.org/licenses/bsd-license.php)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Home: https://github.com/gorhill/lz4-wasm
|
||||
|
||||
I used the same license as the one picked by creator of LZ4 out of respect
|
||||
for his creation, see https://lz4.github.io/lz4/
|
||||
|
||||
*/
|
||||
|
||||
/* global WebAssembly */
|
||||
|
||||
'use strict';
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
(function(context) { // >>>> Start of private namespace
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
let wd = (function() {
|
||||
let url = document.currentScript.src;
|
||||
let match = /[^\/]+$/.exec(url);
|
||||
return match !== null ?
|
||||
url.slice(0, match.index) :
|
||||
'';
|
||||
})();
|
||||
|
||||
let growMemoryTo = function(wasmInstance, byteLength) {
|
||||
let lz4api = wasmInstance.exports;
|
||||
let neededByteLength = lz4api.getLinearMemoryOffset() + byteLength;
|
||||
let pageCountBefore = lz4api.memory.buffer.byteLength >>> 16;
|
||||
let pageCountAfter = (neededByteLength + 65535) >>> 16;
|
||||
if ( pageCountAfter > pageCountBefore ) {
|
||||
lz4api.memory.grow(pageCountAfter - pageCountBefore);
|
||||
}
|
||||
return lz4api.memory.buffer;
|
||||
};
|
||||
|
||||
let encodeBlock = function(wasmInstance, inputArray, outputOffset) {
|
||||
let lz4api = wasmInstance.exports;
|
||||
let mem0 = lz4api.getLinearMemoryOffset();
|
||||
let hashTableSize = 65536 * 4;
|
||||
let inputSize = inputArray.byteLength;
|
||||
if ( inputSize >= 0x7E000000 ) { throw new RangeError(); }
|
||||
let memSize =
|
||||
hashTableSize +
|
||||
inputSize +
|
||||
outputOffset + lz4api.lz4BlockEncodeBound(inputSize);
|
||||
let memBuffer = growMemoryTo(wasmInstance, memSize);
|
||||
let hashTable = new Int32Array(memBuffer, mem0, 65536);
|
||||
hashTable.fill(-65536, 0, 65536);
|
||||
let inputMem = new Uint8Array(memBuffer, mem0 + hashTableSize, inputSize);
|
||||
inputMem.set(inputArray);
|
||||
let outputSize = lz4api.lz4BlockEncode(
|
||||
mem0 + hashTableSize,
|
||||
inputSize,
|
||||
mem0 + hashTableSize + inputSize + outputOffset
|
||||
);
|
||||
if ( outputSize === 0 ) { return; }
|
||||
let outputArray = new Uint8Array(
|
||||
memBuffer,
|
||||
mem0 + hashTableSize + inputSize,
|
||||
outputOffset + outputSize
|
||||
);
|
||||
return outputArray;
|
||||
};
|
||||
|
||||
let decodeBlock = function(wasmInstance, inputArray, inputOffset, outputSize) {
|
||||
let inputSize = inputArray.byteLength;
|
||||
let lz4api = wasmInstance.exports;
|
||||
let mem0 = lz4api.getLinearMemoryOffset();
|
||||
let memSize = inputSize + outputSize;
|
||||
let memBuffer = growMemoryTo(wasmInstance, memSize);
|
||||
let inputArea = new Uint8Array(memBuffer, mem0, inputSize);
|
||||
inputArea.set(inputArray);
|
||||
outputSize = lz4api.lz4BlockDecode(
|
||||
mem0 + inputOffset,
|
||||
inputSize - inputOffset,
|
||||
mem0 + inputSize
|
||||
);
|
||||
if ( outputSize === 0 ) { return; }
|
||||
return new Uint8Array(memBuffer, mem0 + inputSize, outputSize);
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
context.LZ4BlockWASM = function() {
|
||||
this.lz4wasmInstance = undefined;
|
||||
};
|
||||
|
||||
context.LZ4BlockWASM.prototype = {
|
||||
flavor: 'wasm',
|
||||
|
||||
init: function() {
|
||||
if (
|
||||
typeof WebAssembly !== 'object' ||
|
||||
typeof WebAssembly.instantiateStreaming !== 'function'
|
||||
) {
|
||||
this.lz4wasmInstance = null;
|
||||
}
|
||||
if ( this.lz4wasmInstance === null ) {
|
||||
return Promise.reject();
|
||||
}
|
||||
if ( this.lz4wasmInstance instanceof WebAssembly.Instance ) {
|
||||
return Promise.resolve(this.lz4wasmInstance);
|
||||
}
|
||||
if ( this.lz4wasmInstance === undefined ) {
|
||||
this.lz4wasmInstance = WebAssembly.instantiateStreaming(
|
||||
fetch(wd + 'lz4-block-codec.wasm', { mode: 'same-origin' })
|
||||
).then(result => {
|
||||
this.lz4wasmInstance = undefined;
|
||||
this.lz4wasmInstance = result && result.instance || null;
|
||||
if ( this.lz4wasmInstance !== null ) { return this; }
|
||||
return null;
|
||||
});
|
||||
this.lz4wasmInstance.catch(( ) => {
|
||||
this.lz4wasmInstance = null;
|
||||
return null;
|
||||
});
|
||||
}
|
||||
return this.lz4wasmInstance;
|
||||
},
|
||||
|
||||
reset: function() {
|
||||
this.lz4wasmInstance = undefined;
|
||||
},
|
||||
|
||||
bytesInUse: function() {
|
||||
return this.lz4wasmInstance instanceof WebAssembly.Instance ?
|
||||
this.lz4wasmInstance.exports.memory.buffer.byteLength :
|
||||
0;
|
||||
},
|
||||
|
||||
encodeBlock: function(input, outputOffset) {
|
||||
if ( this.lz4wasmInstance instanceof WebAssembly.Instance === false ) {
|
||||
throw new Error('LZ4BlockWASM: not initialized');
|
||||
}
|
||||
if ( input instanceof ArrayBuffer ) {
|
||||
input = new Uint8Array(input);
|
||||
} else if ( input instanceof Uint8Array === false ) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return encodeBlock(this.lz4wasmInstance, input, outputOffset);
|
||||
},
|
||||
|
||||
decodeBlock: function(input, inputOffset, outputSize) {
|
||||
if ( this.lz4wasmInstance instanceof WebAssembly.Instance === false ) {
|
||||
throw new Error('LZ4BlockWASM: not initialized');
|
||||
}
|
||||
if ( input instanceof ArrayBuffer ) {
|
||||
input = new Uint8Array(input);
|
||||
} else if ( input instanceof Uint8Array === false ) {
|
||||
throw new TypeError();
|
||||
}
|
||||
return decodeBlock(this.lz4wasmInstance, input, inputOffset, outputSize);
|
||||
}
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
})(this || self); // <<<< End of private namespace
|
||||
|
||||
/******************************************************************************/
|
||||
@@ -0,0 +1,169 @@
|
||||
/*******************************************************************************
|
||||
|
||||
lz4-block-codec-any.js
|
||||
A wrapper to instanciate a wasm- and/or js-based LZ4 block
|
||||
encoder/decoder.
|
||||
Copyright (C) 2018 Raymond Hill
|
||||
|
||||
BSD-2-Clause License (http://www.opensource.org/licenses/bsd-license.php)
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are
|
||||
met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above
|
||||
copyright notice, this list of conditions and the following disclaimer
|
||||
in the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||
OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Home: https://github.com/gorhill/lz4-wasm
|
||||
|
||||
I used the same license as the one picked by creator of LZ4 out of respect
|
||||
for his creation, see https://lz4.github.io/lz4/
|
||||
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
(function(context) { // >>>> Start of private namespace
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
let wd = (function() {
|
||||
let url = document.currentScript.src;
|
||||
let match = /[^\/]+$/.exec(url);
|
||||
return match !== null ?
|
||||
url.slice(0, match.index) :
|
||||
'';
|
||||
})();
|
||||
|
||||
let removeScript = function(script) {
|
||||
if ( !script ) { return; }
|
||||
if ( script.parentNode === null ) { return; }
|
||||
script.parentNode.removeChild(script);
|
||||
};
|
||||
|
||||
let createInstanceWASM = function() {
|
||||
if ( context.LZ4BlockWASM instanceof Function ) {
|
||||
let instance = new context.LZ4BlockWASM();
|
||||
return instance.init().then(( ) => { return instance; });
|
||||
}
|
||||
if ( context.LZ4BlockWASM === null ) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement('script');
|
||||
script.src = wd + 'lz4-block-codec-wasm.js';
|
||||
script.addEventListener('load', ( ) => {
|
||||
if ( context.LZ4BlockWASM instanceof Function === false ) {
|
||||
context.LZ4BlockWASM = null;
|
||||
context.LZ4BlockWASM = undefined;
|
||||
resolve(null);
|
||||
} else {
|
||||
let instance = new context.LZ4BlockWASM();
|
||||
instance.init()
|
||||
.then(( ) => {
|
||||
resolve(instance);
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
script.addEventListener('error', ( ) => {
|
||||
context.LZ4BlockWASM = null;
|
||||
resolve(null);
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
removeScript(script);
|
||||
});
|
||||
};
|
||||
|
||||
let createInstanceJS = function() {
|
||||
if ( context.LZ4BlockJS instanceof Function ) {
|
||||
let instance = new context.LZ4BlockJS();
|
||||
return instance.init().then(( ) => { return instance; });
|
||||
}
|
||||
if ( context.LZ4BlockJS === null ) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement('script');
|
||||
script.src = wd + 'lz4-block-codec-js.js';
|
||||
script.addEventListener('load', ( ) => {
|
||||
if ( context.LZ4BlockJS instanceof Function === false ) {
|
||||
context.LZ4BlockJS = null;
|
||||
resolve(null);
|
||||
} else {
|
||||
let instance = new context.LZ4BlockJS();
|
||||
instance.init()
|
||||
.then(( ) => {
|
||||
resolve(instance);
|
||||
})
|
||||
.catch(error => {
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
});
|
||||
script.addEventListener('error', ( ) => {
|
||||
context.LZ4BlockJS = null;
|
||||
resolve(null);
|
||||
});
|
||||
document.head.appendChild(script);
|
||||
removeScript(script);
|
||||
});
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
context.lz4BlockCodec = {
|
||||
createInstance: function(flavor) {
|
||||
let instantiator;
|
||||
if ( flavor === 'wasm' ) {
|
||||
instantiator = createInstanceWASM;
|
||||
} else if ( flavor === 'js' ) {
|
||||
instantiator = createInstanceJS;
|
||||
} else {
|
||||
instantiator = createInstanceWASM || createInstanceJS;
|
||||
}
|
||||
return (instantiator)()
|
||||
.then(instance => {
|
||||
if ( instance ) { return instance; }
|
||||
if ( flavor === undefined ) {
|
||||
return createInstanceJS();
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.catch(( ) => {
|
||||
if ( flavor === undefined ) {
|
||||
return createInstanceJS();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
},
|
||||
reset: function() {
|
||||
context.LZ4BlockWASM = undefined;
|
||||
context.LZ4BlockJS = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/******************************************************************************/
|
||||
|
||||
})(this || self); // <<<< End of private namespace
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "Audiomass - Audio Editor",
|
||||
"short_name": "Audiomass - Audio Editor",
|
||||
"start_url": ".",
|
||||
"scope": ".",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{
|
||||
"src": "icon.png",
|
||||
"type": "image/png",
|
||||
"sizes": "144x144"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
(function ( w, d ) {
|
||||
|
||||
var _id = 0;
|
||||
|
||||
function PKSimpleModal ( config ) {
|
||||
var q = this;
|
||||
|
||||
this.id = config.id ? config.id : (++_id);
|
||||
|
||||
var el = d.createElement ('div');
|
||||
this.els = {
|
||||
toolbar:[],
|
||||
bottom:[]
|
||||
};
|
||||
el.className = 'pk_modal ' + (config.clss ? config.clss : '');
|
||||
|
||||
q.el = el;
|
||||
|
||||
// backdrop
|
||||
var el_back = d.createElement ('div');
|
||||
el_back.className = 'pk_modal_back';
|
||||
this.el_back = el_back;
|
||||
|
||||
// var centerer
|
||||
var el_cont = d.createElement ('div');
|
||||
el_cont.className = 'pk_modal_cnt';
|
||||
this.el_cont = el_cont;
|
||||
|
||||
// title
|
||||
var el_title = d.createElement ('div');
|
||||
el_title.className = 'pk_noselect pk_modal_title';
|
||||
el_title.innerHTML = '<span>'+ (config.title || '') +'</span>';
|
||||
el.appendChild ( el_title );
|
||||
this.el_title = el_title;
|
||||
|
||||
// main
|
||||
var el_main = d.createElement ('div');
|
||||
el_main.className = 'pk_modal_main';
|
||||
el.appendChild ( el_main );
|
||||
this.el_body = el_main;
|
||||
|
||||
// bottom buttons
|
||||
var el_bottom = d.createElement ('div');
|
||||
el_bottom.className = 'pk_noselect pk_modal_bottom';
|
||||
// -----------
|
||||
var a_cancel = d.createElement ('a');
|
||||
a_cancel.innerHTML = 'CANCEL';
|
||||
a_cancel.className = 'pk_modal_cancel pk_modal_a_bottom';
|
||||
a_cancel.onclick = function () {
|
||||
q.Destroy ();
|
||||
};
|
||||
el_bottom.appendChild ( a_cancel );
|
||||
|
||||
// check if we need to construct more buttons from the config...
|
||||
if (config.buttons && config.buttons.length > 0)
|
||||
{
|
||||
for (var i = 0; i < config.buttons.length; ++i)
|
||||
{
|
||||
var curr = config.buttons[i];
|
||||
|
||||
if (!curr.title || !curr.callback) continue;
|
||||
var a_bottom = d.createElement ('a');
|
||||
a_bottom.innerHTML = curr.title;
|
||||
a_bottom.className = 'pk_modal_a_bottom ' + (curr.clss ? curr.clss : '');
|
||||
|
||||
if (curr.callback)
|
||||
{
|
||||
(function ( callback ) {
|
||||
a_bottom.onclick = function () {
|
||||
callback ( q );
|
||||
};
|
||||
})( curr.callback );
|
||||
}
|
||||
q.els.bottom.push (a_bottom);
|
||||
el_bottom.appendChild ( a_bottom );
|
||||
}
|
||||
}
|
||||
el.appendChild ( el_bottom );
|
||||
|
||||
// -----
|
||||
if (config.toolbar && config.toolbar.length > 0)
|
||||
{
|
||||
for (var i = 0; i < config.toolbar.length; ++i)
|
||||
{
|
||||
var curr = config.toolbar[i];
|
||||
if (!curr.title || !curr.callback) continue;
|
||||
var a_link = d.createElement ('a');
|
||||
a_link.innerHTML = curr.title;
|
||||
a_link.className = 'pk_modal_a_top ' + (curr.clss ? curr.clss : '');
|
||||
el_title.appendChild ( a_link );
|
||||
|
||||
if (curr.callback)
|
||||
{
|
||||
(function ( callback ) {
|
||||
a_link.onclick = function () {
|
||||
callback ( q, this );
|
||||
};
|
||||
})( curr.callback );
|
||||
}
|
||||
q.els.toolbar.push (a_link);
|
||||
}
|
||||
}
|
||||
|
||||
this.ondestroy = config.ondestroy;
|
||||
if (config.body) q.el_body.innerHTML = config.body;
|
||||
if (config.onpreset) this.onpreset = config.onpreset;
|
||||
if (config.setup) config.setup ( this );
|
||||
};
|
||||
|
||||
PKSimpleModal.prototype.Show = function () {
|
||||
|
||||
this.el_back.appendChild ( this.el_cont );
|
||||
this.el_cont.appendChild ( this.el );
|
||||
|
||||
d.body.appendChild ( this.el_back );
|
||||
|
||||
return (this);
|
||||
};
|
||||
|
||||
PKSimpleModal.prototype.Destroy = function () {
|
||||
|
||||
if (this.ondestroy) {
|
||||
this.ondestroy ( this );
|
||||
this.ondestroy = null;
|
||||
}
|
||||
this.els = null;
|
||||
d.body.removeChild ( this.el_back );
|
||||
};
|
||||
|
||||
|
||||
// Extended modal
|
||||
function PKAudioFXModal ( config, app ) {
|
||||
var toolbar = null;
|
||||
|
||||
if (config.preview)
|
||||
{
|
||||
toolbar = [
|
||||
{
|
||||
title:'ON',
|
||||
clss:'pk_inact',
|
||||
callback: function ( q, el ) {
|
||||
app.fireEvent ('RequestActionFX_TOGGLE');
|
||||
}
|
||||
},
|
||||
{
|
||||
title:'Preview',
|
||||
callback: function ( q ) {
|
||||
config.preview && config.preview ( q );
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
var inner_modal = new PKSimpleModal({
|
||||
id: config.id,
|
||||
title: config.title,
|
||||
clss: config.clss,
|
||||
presets: config.presets,
|
||||
updateFilter: config.updateFilter,
|
||||
ondestroy: function ( q ) {
|
||||
|
||||
app.fireEvent ('DidCloseFX_UI');
|
||||
|
||||
app.stopListeningFor ('DidStartPreview', q._evstart);
|
||||
app.stopListeningFor ('DidStopPreview', q._evstop);
|
||||
app.stopListeningFor ('DidTogglePreview', q._evtoggle);
|
||||
app.stopListeningFor ('DidSetPresets', q._updatePresets);
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
app.stopListeningFor ('RequestSetPresetActive', q._updpreset);
|
||||
|
||||
app.fireEvent ('RequestActionFX_PREVIEW_STOP');
|
||||
|
||||
// if preview remove callback
|
||||
app.ui.KeyHandler.removeCallback ('ksp' + q.id);
|
||||
|
||||
|
||||
config.ondestroy && config.ondestroy ( q );
|
||||
},
|
||||
toolbar: toolbar,
|
||||
buttons: config.buttons,
|
||||
body: config.body,
|
||||
onpreset: config.onpreset,
|
||||
setup:function( q ) {
|
||||
app.fireEvent ('RequestActionFX_TOGGLE', 1);
|
||||
|
||||
var slf = this;
|
||||
app.ui.KeyHandler.addCallback ('ksp' + q.id, function ( key, map ) {
|
||||
if (!app.ui.InteractionHandler.check ('modalfx')) return ;
|
||||
|
||||
var tb = slf.toolbar;
|
||||
if (tb && tb.length > 0)
|
||||
{
|
||||
var k = tb.length;
|
||||
while (k-- > 0) {
|
||||
if (tb[k].title === 'Preview') {
|
||||
tb[k].callback ( q );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [32]);
|
||||
|
||||
q._evstart = function () {
|
||||
q.els.toolbar[0].classList.remove ('pk_inact');
|
||||
q.els.toolbar[1].classList.add ('pk_act');
|
||||
};
|
||||
q._evstop = function () {
|
||||
q.els.toolbar[0].classList.add ('pk_inact');
|
||||
q.els.toolbar[1].classList.remove ('pk_act');
|
||||
}
|
||||
|
||||
q._evtoggle = function ( val ) {
|
||||
var el = q.els.toolbar[0];
|
||||
if (val) el.innerHTML = 'ON';
|
||||
else el.innerHTML = 'OFF';
|
||||
};
|
||||
|
||||
var stopped_listening = false;
|
||||
q._updpreview = function ( val ) {
|
||||
var sel_opt = q.el_presets.options[q.el_presets.selectedIndex];
|
||||
var btn = q.el.getElementsByClassName('pk_sel_edt')[0];
|
||||
|
||||
if (val === 't')
|
||||
{
|
||||
if (sel_opt && sel_opt.getAttribute('data-custom')) {
|
||||
btn.style.visibility = 'visible';
|
||||
btn.style.opacity = '1';
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
}
|
||||
else
|
||||
{
|
||||
btn.style.visibility = 'hidden';
|
||||
btn.style.opacity = '0';
|
||||
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
|
||||
//if (stopped_listening)
|
||||
//{
|
||||
setTimeout(function (){
|
||||
app.listenFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
}, 100);
|
||||
stopped_listening = false;
|
||||
//}
|
||||
}
|
||||
return ;
|
||||
}
|
||||
|
||||
// if (sel_opt && sel_opt.getAttribute('data-custom')) {
|
||||
btn.style.visibility = 'visible';
|
||||
btn.style.opacity = '1';
|
||||
stopped_listening = true;
|
||||
app.stopListeningFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
// }
|
||||
};
|
||||
|
||||
q._updpreset = function ( fx_id, preset_id ) {
|
||||
if (fx_id && fx_id !== q.id) {
|
||||
return ;
|
||||
}
|
||||
|
||||
var opts = q.el_presets.getElementsByTagName('option');
|
||||
var ll = opts.length;
|
||||
var curr = null;
|
||||
|
||||
while (ll-- > 0) {
|
||||
curr = opts[ll];
|
||||
|
||||
if (curr.getAttribute('data-custom') === preset_id) {
|
||||
curr.selected = 'selected';
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
q._updatePresets = function ( fx_id, presets ) {
|
||||
if (fx_id && fx_id !== q.id) {
|
||||
return ;
|
||||
}
|
||||
|
||||
var d = document;
|
||||
var sel_presets = q.el.getElementsByClassName ('pk_sel');
|
||||
|
||||
// if presets exist remove them
|
||||
if (sel_presets.length > 0)
|
||||
{
|
||||
sel_presets = sel_presets[0];
|
||||
var opts = sel_presets.getElementsByTagName('option');
|
||||
var ll = opts.length;
|
||||
var curr = null;
|
||||
|
||||
while (ll-- > 0) {
|
||||
curr = opts[ll];
|
||||
|
||||
if (curr.getAttribute('data-custom')) {
|
||||
sel_presets.removeChild( curr );
|
||||
}
|
||||
}
|
||||
|
||||
if (presets.length === 0) return ;
|
||||
|
||||
var opt = d.createElement ('option');
|
||||
// opt.value = '---custom----';
|
||||
opt.setAttribute ('disabled', '1');
|
||||
opt.setAttribute ('data-custom', '1');
|
||||
opt.innerHTML = '----custom-----';
|
||||
sel_presets.appendChild( opt );
|
||||
|
||||
for (var i = 0; i < presets.length; ++i)
|
||||
{
|
||||
var opt = d.createElement ('option');
|
||||
var curr = presets[ i ];
|
||||
opt.value = curr.val;
|
||||
opt.setAttribute ('data-custom', curr.id);
|
||||
opt.innerHTML = curr.name;
|
||||
sel_presets.appendChild( opt );
|
||||
}
|
||||
|
||||
return ;
|
||||
}
|
||||
else
|
||||
{
|
||||
sel_presets = d.createElement ('select');
|
||||
sel_presets.className = 'pk_sel';
|
||||
}
|
||||
|
||||
if (presets.length === 0) return ;
|
||||
|
||||
|
||||
for (var i = -1; i < presets.length; ++i)
|
||||
{
|
||||
var opt = d.createElement ('option');
|
||||
|
||||
if (i === -1)
|
||||
{
|
||||
opt.value = 'null';
|
||||
opt.innerHTML = 'Presets';
|
||||
}
|
||||
else
|
||||
{
|
||||
var curr = presets[ i ];
|
||||
opt.value = curr.val;
|
||||
opt.innerHTML = curr.name;
|
||||
}
|
||||
sel_presets.appendChild( opt );
|
||||
}
|
||||
|
||||
sel_presets.onchange = function () {
|
||||
var val_arr = this.value.split(',');
|
||||
var els = q.el.getElementsByTagName('input');
|
||||
|
||||
q._updpreview ('t');
|
||||
|
||||
if (q.onpreset)
|
||||
{
|
||||
q.onpreset (this.value);
|
||||
return ;
|
||||
}
|
||||
|
||||
var len = els.length;
|
||||
|
||||
for (var i = 0; i < len; ++i) {
|
||||
if (!val_arr[ i ]) break;
|
||||
|
||||
var curr_val = val_arr[ i ].trim ();
|
||||
var curr_input = els[ i ];
|
||||
|
||||
if (curr_val === 'null') continue;
|
||||
|
||||
if (curr_input.type === 'checkbox' || curr_input.type === 'radio')
|
||||
curr_input.checked = curr_val;
|
||||
else
|
||||
{
|
||||
curr_input.value = curr_val;
|
||||
curr_input.oninput && curr_input.oninput.apply (curr_input);
|
||||
}
|
||||
}
|
||||
};
|
||||
var btm = q.el.getElementsByClassName('pk_modal_bottom')[0];
|
||||
|
||||
btm.appendChild ( sel_presets );
|
||||
q.el_presets = sel_presets;
|
||||
|
||||
// now add preset edit button
|
||||
var edit_presets = d.createElement ('a');
|
||||
edit_presets.className = 'pk_sel_edt';
|
||||
edit_presets.innerHTML = '...<span>Save or Modify preset</span>';
|
||||
edit_presets.onclick = function () {
|
||||
app.fireEvent ('RequestSavePreset');
|
||||
};
|
||||
|
||||
btm.appendChild ( edit_presets );
|
||||
app.listenFor ('RequestActionFX_UPDATE_PREVIEW', q._updpreview);
|
||||
app.listenFor ('RequestSetPresetActive', q._updpreset);
|
||||
};
|
||||
|
||||
|
||||
app.listenFor ('DidStartPreview', q._evstart);
|
||||
app.listenFor ('DidStopPreview', q._evstop);
|
||||
app.listenFor ('DidTogglePreview', q._evtoggle);
|
||||
app.fireEvent ('DidOpenFX_UI', q);
|
||||
|
||||
if (config.updateFilter) q.updateFilter = config.updateFilter;
|
||||
|
||||
if (config.presets)
|
||||
{
|
||||
q._updatePresets (null, config.presets);
|
||||
if (config.custom_pres) q._updatePresets (null, config.custom_pres);
|
||||
|
||||
app.listenFor ('DidSetPresets', q._updatePresets);
|
||||
}
|
||||
|
||||
config.setup && config.setup ( q );
|
||||
}
|
||||
});
|
||||
|
||||
return (inner_modal);
|
||||
};
|
||||
|
||||
w.PKSimpleModal = PKSimpleModal;
|
||||
w.PKAudioFXModal = PKAudioFXModal;
|
||||
})( window, document );
|
||||
@@ -0,0 +1,29 @@
|
||||
(function ( w, d ) {
|
||||
|
||||
function OneUp ( _text, _time, _clss ) {
|
||||
var el = d.createElement ('div');
|
||||
var cl = 'pk_oneup pk_noselect';
|
||||
|
||||
el.style.cssText = 'margin-top:20px;opacity:0';
|
||||
if (_clss) cl = cl + ' ' + _clss;
|
||||
|
||||
el.className = cl;
|
||||
el.innerHTML = _text || '';
|
||||
|
||||
d.body.appendChild ( el );
|
||||
setTimeout (function() {
|
||||
el.style.cssText = 'margin-top:0px;opacity:1';
|
||||
|
||||
setTimeout (function() {
|
||||
el.style.cssText = 'margin-top:-20px;opacity:0';
|
||||
|
||||
setTimeout (function() {
|
||||
el.parentNode.removeChild ( el );
|
||||
el = null;
|
||||
}, 330);
|
||||
}, _time || 720);
|
||||
}, 25);
|
||||
}
|
||||
|
||||
w.OneUp = OneUp;
|
||||
})( window, document );
|
||||
|
After Width: | Height: | Size: 64 KiB |
@@ -0,0 +1,223 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
var PKREC = function ( app ) {
|
||||
var q = this;
|
||||
|
||||
var media_stream_source = null;
|
||||
var audio_stream = null;
|
||||
var audio_context = null;
|
||||
var script_processor = null;
|
||||
|
||||
var buffer_size = 2048 * 2; // * 2 ?
|
||||
var channel_num = 1;
|
||||
var channel_num_out = 1;
|
||||
|
||||
var is_active = false;
|
||||
|
||||
var starting_offset = 0;
|
||||
var ending_offset = 0;
|
||||
|
||||
var sample_rate = 0;
|
||||
|
||||
var temp_buffers = [];
|
||||
var temp_buffer_index = -1;
|
||||
var jumps = 1;
|
||||
|
||||
var end_record_func = null;
|
||||
var start_record_func = null;
|
||||
|
||||
// temp vars
|
||||
var curr_offset = 0;
|
||||
var first_skip = 8; // skip first samples to evade the button's click
|
||||
var fetchBufferFunction = function( ev ) {
|
||||
|
||||
if (first_skip > 0) {
|
||||
--first_skip;
|
||||
return ;
|
||||
}
|
||||
|
||||
curr_offset += ev.inputBuffer.duration * sample_rate;
|
||||
if (ending_offset <= curr_offset)
|
||||
{
|
||||
ending_offset > 0 && q.stop ();
|
||||
return ;
|
||||
}
|
||||
|
||||
var float_array = ev.inputBuffer.getChannelData (0).slice (0);
|
||||
temp_buffers[ ++temp_buffer_index ] = float_array;
|
||||
|
||||
if (--jumps === 0)
|
||||
{
|
||||
requestAnimationFrame(function(){
|
||||
jumps = 4;
|
||||
app.engine.wavesurfer.DrawTemp ( starting_offset, temp_buffers );
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
this.isActive = function () {
|
||||
return (is_active);
|
||||
};
|
||||
|
||||
this.setEndingOffset = function ( ending_offset_seconds ) {
|
||||
ending_offset = ending_offset_seconds; // #### * 100
|
||||
};
|
||||
|
||||
this.start = function ( _at_offset, _end_callback, _start_callback, _sample_rate ) {
|
||||
if (is_active) return (false);
|
||||
if (!navigator.mediaDevices)
|
||||
{
|
||||
app.fireEvent ('ErrorRec');
|
||||
app.fireEvent ('ShowError', 'No recording device found');
|
||||
return (false);
|
||||
}
|
||||
|
||||
starting_offset = _at_offset / 1;
|
||||
if (isNaN (starting_offset) || !starting_offset) starting_offset = 0;
|
||||
curr_offset = starting_offset;
|
||||
|
||||
audio_context = app.engine.wavesurfer.backend.getAudioContext ();
|
||||
if (!audio_context)
|
||||
{
|
||||
app.fireEvent ('ErrorRec');
|
||||
app.fireEvent ('ShowError', 'No recording device found');
|
||||
return (false);
|
||||
}
|
||||
|
||||
if (audio_context.currentTime === 0) {
|
||||
app.engine.wavesurfer.backend.source.start (0);
|
||||
app.engine.wavesurfer.backend.source.stop (0);
|
||||
}
|
||||
|
||||
if (!_sample_rate)
|
||||
{
|
||||
if (app.engine.wavesurfer.backend.buffer) {
|
||||
sample_rate = app.engine.wavesurfer.backend.buffer.sampleRate;
|
||||
}
|
||||
else {
|
||||
sample_rate = audio_context.sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
end_record_func = function (offset, buffers, _callback) {
|
||||
async function downsampleAudioBuffer(buffers, sourceSampleRate, targetSampleRate) {
|
||||
// Step 1: Concatenate the Float32Array chunks
|
||||
const totalLength = buffers.reduce((sum, buf) => sum + buf.length, 0);
|
||||
const concatenated = new Float32Array(totalLength);
|
||||
let offset = 0;
|
||||
for (let i = 0; i < buffers.length; i++) {
|
||||
concatenated.set(buffers[i], offset);
|
||||
offset += buffers[i].length;
|
||||
}
|
||||
|
||||
// Step 2: Create an AudioBuffer from the concatenated data at the source sample rate
|
||||
// Create a temporary AudioContext to build the AudioBuffer
|
||||
const tempCtx = new AudioContext({ sampleRate: sourceSampleRate });
|
||||
const audioBuffer = tempCtx.createBuffer(1, totalLength, sourceSampleRate);
|
||||
audioBuffer.copyToChannel(concatenated, 0, 0);
|
||||
|
||||
// Release the temporary context if you don't need it anymore
|
||||
tempCtx.close();
|
||||
|
||||
// Step 3: Use an OfflineAudioContext to resample the AudioBuffer to the target sample rate
|
||||
const duration = audioBuffer.duration;
|
||||
const newLength = Math.ceil(duration * targetSampleRate);
|
||||
const offlineCtx = new OfflineAudioContext(1, newLength, targetSampleRate);
|
||||
|
||||
const source = offlineCtx.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(offlineCtx.destination);
|
||||
source.start(0);
|
||||
|
||||
// Render the resampled AudioBuffer
|
||||
const renderedBuffer = await offlineCtx.startRendering();
|
||||
|
||||
// Return the downsampled Float32Array
|
||||
return renderedBuffer.getChannelData(0);
|
||||
}
|
||||
|
||||
var source_sample_rate = audio_context ? audio_context.sampleRate : 48000;
|
||||
if (source_sample_rate === sample_rate) {
|
||||
_callback ();
|
||||
_end_callback (offset, buffers);
|
||||
return ;
|
||||
}
|
||||
|
||||
downsampleAudioBuffer(buffers, source_sample_rate, sample_rate).then(newBuffer => {
|
||||
_callback ();
|
||||
_end_callback (offset, [newBuffer]);
|
||||
}).catch(error => {
|
||||
_callback ();
|
||||
console.error("Error during downsampling:", error);
|
||||
});
|
||||
|
||||
// _end_callback (offset, buffers);
|
||||
};
|
||||
start_record_func = _start_callback;
|
||||
|
||||
navigator.mediaDevices.getUserMedia({ audio: true, video: false }).then(function( stream ) {
|
||||
audio_stream = stream;
|
||||
media_stream_source = audio_context.createMediaStreamSource ( stream );
|
||||
|
||||
script_processor = audio_context.createScriptProcessor (
|
||||
buffer_size, channel_num, channel_num_out
|
||||
);
|
||||
|
||||
media_stream_source.connect ( script_processor );
|
||||
script_processor.connect ( audio_context.destination );
|
||||
|
||||
is_active = true;
|
||||
start_record_func && start_record_func ();
|
||||
|
||||
script_processor.onaudioprocess = fetchBufferFunction;
|
||||
}).catch(function(error) {
|
||||
app.fireEvent ('ErrorRec');
|
||||
|
||||
if (error && error.message)
|
||||
{
|
||||
app.fireEvent ('ShowError', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
return (true);
|
||||
};
|
||||
|
||||
this.stop = function ( cancel_recording ) {
|
||||
if (!is_active) return ;
|
||||
|
||||
// fire one last callback to clean temp_buffers?
|
||||
audio_stream.getTracks().forEach(function (stream) {
|
||||
stream.stop ();
|
||||
});
|
||||
|
||||
script_processor.onaudioprocess = null;
|
||||
media_stream_source.disconnect ();
|
||||
script_processor.disconnect ();
|
||||
|
||||
app.engine.wavesurfer.DrawTemp ( null );
|
||||
|
||||
if (temp_buffers.length > 0 && !cancel_recording)
|
||||
end_record_func && end_record_func ( starting_offset / sample_rate, temp_buffers, function (){
|
||||
is_active = false;
|
||||
});
|
||||
else
|
||||
end_record_func && end_record_func ( null, null, function(){
|
||||
is_active = false;
|
||||
});
|
||||
|
||||
sample_rate = 0;
|
||||
first_skip = 8;
|
||||
jumps = 1;
|
||||
temp_buffer_index = -1;
|
||||
starting_offset = ending_offset = 0;
|
||||
temp_buffers = [];
|
||||
audio_stream = null; audio_context = null;
|
||||
end_record_func = start_record_func = null;
|
||||
};
|
||||
// ---
|
||||
};
|
||||
|
||||
PKAE._deps.rec = PKREC;
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<script>window.update = function(){};</script>
|
||||
|
||||
<title>AudioMass - Spectrum Analyzer</title>
|
||||
<style>
|
||||
html,body{height:100%}
|
||||
body{padding:0;margin:0;background:#111;position:relative;z-index:2;overflow:hidden;}
|
||||
div{position:absolute;top:50%;left:0;right:0;z-index:1;margin-top:-18px;text-align:center;user-select:none;
|
||||
font-size:28px;color:#191919;pointer-events:none;opacity:.75;font-family:Arial}
|
||||
#e, #d, #f{
|
||||
cursor:pointer;
|
||||
position: absolute;
|
||||
top: 7px;
|
||||
right: 7px;
|
||||
display: block;
|
||||
height: 20px;
|
||||
z-index: 99999;
|
||||
background: #eaeaea;
|
||||
left:auto;
|
||||
border-radius:4px;
|
||||
font-family: sans-serif;
|
||||
font-size: 9px;
|
||||
width: 82px;
|
||||
line-height: 20px;
|
||||
padding: 0 6px;
|
||||
text-align: center;
|
||||
opacity:0.9;
|
||||
user-select:none;
|
||||
}
|
||||
|
||||
.b #e, .b #d{
|
||||
top:12px;
|
||||
}
|
||||
|
||||
.b #e, .b #f{
|
||||
display:block;
|
||||
}
|
||||
|
||||
#e{
|
||||
right: 106px;
|
||||
width: 10px;
|
||||
line-height: 20px;
|
||||
padding: 0px 6px;
|
||||
text-align: center;
|
||||
opacity: 0.9;
|
||||
display:none;
|
||||
}
|
||||
|
||||
#f{
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
border-radius: 0;
|
||||
height: 4px;
|
||||
background: #333;
|
||||
display:none;
|
||||
user-select:none;
|
||||
-moz-user-select:none;
|
||||
}
|
||||
|
||||
.c #f, #f:hover{
|
||||
background:#3C3C3C;
|
||||
}
|
||||
|
||||
</style>
|
||||
<meta charset="utf-8" />
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="fr" width="600" height="188" style="width:100%;height:100%;display:block"></canvas>
|
||||
<div>SPECTRUM ANALYSER</div>
|
||||
|
||||
<a id="d" onclick="dock()">DOCK INTERFACE</a>
|
||||
<a id="e" onclick="remove()">X</a>
|
||||
<a id="f" onmousedown="drag(event)"></a>
|
||||
|
||||
<script>
|
||||
var d = document;
|
||||
var w = window;
|
||||
var iframe = location.href.indexOf('?iframe') === -1 ? 0 : 1;
|
||||
var canvas = d.getElementById('fr');
|
||||
var ctx = canvas.getContext('2d', {alpha:true, antialias:false});
|
||||
|
||||
w.remove = function (){};
|
||||
if (iframe) {
|
||||
d.body.className = 'b';
|
||||
w.remove = function () {
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'sp', [1, 1]);
|
||||
};
|
||||
};
|
||||
|
||||
w.drag = function ( e ) {
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestDragI', 'sp');
|
||||
};
|
||||
|
||||
w.dock = function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) {
|
||||
return ;
|
||||
}
|
||||
|
||||
w.opener.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'sp', [1, 1]);
|
||||
w.close && w.close ();
|
||||
}
|
||||
else
|
||||
{
|
||||
var frm = w.parent.document.getElementById ('pk_fr' + 'sp');
|
||||
var t = 1;
|
||||
|
||||
if (frm && frm.getBoundingClientRect) {
|
||||
var rect = frm.getBoundingClientRect ();
|
||||
t = [(w.parent.screenLeft + rect.left + 100)||0, (w.parent.screenTop + rect.top + 25)||0];
|
||||
}
|
||||
|
||||
w.parent.PKAudioEditor.ui.Dock ('RequestShowFreqAn', 'sp', [t, 0]);
|
||||
}
|
||||
};
|
||||
|
||||
w.getGrayColor = function(value) {
|
||||
return 'rgb(V, V, V)'.replace(/V/g, 255 - value);
|
||||
};
|
||||
|
||||
w.getFullColor = function(value) {
|
||||
var colorPalette = {
|
||||
0: [0,0,0],
|
||||
10: [75, 0, 159],
|
||||
20: [104,0,251],
|
||||
30: [131,0,255],
|
||||
40: [155,18,157],
|
||||
50: [175, 37, 0],
|
||||
60: [191, 59, 0],
|
||||
70: [206, 88, 0],
|
||||
80: [223, 132, 0],
|
||||
90: [240, 188, 0],
|
||||
100: [255, 252, 0]
|
||||
};
|
||||
|
||||
//floor to nearest 10:
|
||||
var decimalised = 100 * value / 255
|
||||
var percent = decimalised / 100;
|
||||
var floored = 10* Math.floor(decimalised / 10);
|
||||
var distFromFloor = decimalised - floored;
|
||||
var distFromFloorPercentage = distFromFloor/10;
|
||||
var rangeToNextColor;
|
||||
|
||||
if (decimalised < 100){
|
||||
rangeToNextColor = [
|
||||
colorPalette[floored + 10][0] - colorPalette[floored + 10][0],
|
||||
colorPalette[floored + 10][1] - colorPalette[floored + 10][1],
|
||||
colorPalette[floored + 10][2] - colorPalette[floored + 10][2]
|
||||
];
|
||||
} else {
|
||||
rangeToNextColor = [0,0,0];
|
||||
}
|
||||
|
||||
var color = [
|
||||
colorPalette[floored][0] + distFromFloorPercentage * rangeToNextColor[0],
|
||||
colorPalette[floored][1] + distFromFloorPercentage * rangeToNextColor[1],
|
||||
colorPalette[floored][2] + distFromFloorPercentage * rangeToNextColor[2]
|
||||
];
|
||||
|
||||
return "rgb(" + color[0] +", "+color[1] +"," + color[2]+")";
|
||||
};
|
||||
|
||||
setTimeout(function () {
|
||||
if (!iframe)
|
||||
{
|
||||
if (!w.opener || !w.opener.PKAudioEditor) return ;
|
||||
}
|
||||
|
||||
var WIDTH = w.innerWidth, HEIGHT = w.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
var bufferLength = 240;
|
||||
var value_changed = false;
|
||||
var speed = 3;
|
||||
|
||||
var tempCanvas = document.createElement('canvas');
|
||||
tempCanvas.width = WIDTH;
|
||||
tempCanvas.height = HEIGHT;
|
||||
|
||||
// console.log(this.canvas.height, this.tempCanvas.height);
|
||||
var tempCtx = tempCanvas.getContext ('2d');
|
||||
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
|
||||
function draw( data ) {
|
||||
|
||||
var WIDTH = window.innerWidth, HEIGHT = window.innerHeight;
|
||||
|
||||
if (canvas.width != WIDTH || canvas.height != HEIGHT)
|
||||
{
|
||||
canvas.width = WIDTH;
|
||||
canvas.height = HEIGHT;
|
||||
tempCanvas.width = WIDTH;
|
||||
tempCanvas.height = HEIGHT;
|
||||
}
|
||||
|
||||
tempCtx.drawImage (canvas, 0, 0, WIDTH, HEIGHT);
|
||||
|
||||
ctx.fillStyle = 'rgb(0, 0, 0)';
|
||||
ctx.fillRect(0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Iterate over the frequencies.
|
||||
for (var i = 0; i < data.length; ++i)
|
||||
{
|
||||
var value;
|
||||
// Draw each pixel with the specific color.
|
||||
// if (this.log) {
|
||||
// logIndex = this.logScale(i, data.length);
|
||||
// value = data[logIndex];
|
||||
// } else {
|
||||
value = data[i];
|
||||
// }
|
||||
|
||||
ctx.fillStyle = window.getFullColor(value);
|
||||
|
||||
var percent = i / data.length;
|
||||
var y = Math.round (percent * HEIGHT);
|
||||
|
||||
// draw the line at the right side of the canvas
|
||||
ctx.fillRect(WIDTH - speed, HEIGHT - y,
|
||||
speed, speed);
|
||||
}
|
||||
|
||||
// Translate the canvas.
|
||||
ctx.translate(-speed, 0);
|
||||
// Draw the copied image.
|
||||
// console.log(this.width, this.height);
|
||||
ctx.drawImage (tempCanvas, 0, 0, WIDTH, HEIGHT,
|
||||
0, 0, WIDTH, HEIGHT);
|
||||
|
||||
// Reset the transformation matrix.
|
||||
ctx.setTransform (1, 0, 0, 1, 0, 0);
|
||||
|
||||
value_changed = false;
|
||||
};
|
||||
w.draw = draw;
|
||||
|
||||
w.onunload = function () {
|
||||
w.destroy && w.destroy ( iframe );
|
||||
w.destroy = null;
|
||||
};
|
||||
|
||||
w.update = function (freq_arr) {
|
||||
if (!freq_arr)
|
||||
ctx.clearRect (0, 0, WIDTH, HEIGHT);
|
||||
else {
|
||||
if (value_changed) return ;
|
||||
value_changed = true;
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
draw (freq_arr);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
var last_press = 0;
|
||||
document.addEventListener ('keypress', function ( e ) {
|
||||
if (e.keyCode !== 32) return ;
|
||||
|
||||
e.preventDefault ();
|
||||
e.stopPropagation ();
|
||||
|
||||
if (e.timeStamp - last_press < 100) {
|
||||
return ;
|
||||
}
|
||||
|
||||
last_press = e.timeStamp;
|
||||
|
||||
if (!iframe) {
|
||||
w.opener && w.opener.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
else {
|
||||
w.parent && w.parent.PKAudioEditor.ui.Dock ('RequestKeyDown', 32);
|
||||
}
|
||||
});
|
||||
|
||||
}, 60);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
(function ( PKAE ) {
|
||||
'use strict';
|
||||
|
||||
function PKState ( _depth, app ) {
|
||||
if (!_depth) _depth = 1;
|
||||
|
||||
var q = this;
|
||||
|
||||
var _id = 1;
|
||||
var _fireEvent = app.fireEvent;
|
||||
var _listenFor = app.listenFor;
|
||||
|
||||
var undo_state_list = [];
|
||||
var redo_state_list = [];
|
||||
|
||||
q.getLastUndoState = function () {
|
||||
return (undo_state_list [ undo_state_list.length - 1]);
|
||||
};
|
||||
|
||||
q.pushUndoState = function ( state ) {
|
||||
if (!state) return (false);
|
||||
|
||||
if (!state.id) state.id = ++_id;
|
||||
if (undo_state_list.length >= _depth) undo_state_list.shift ();
|
||||
|
||||
if (undo_state_list.length > 0)
|
||||
{
|
||||
if (undo_state_list[undo_state_list.length - 1].id !== state.id - 1)
|
||||
undo_state_list = [];
|
||||
}
|
||||
if (redo_state_list.length > 0)
|
||||
{
|
||||
if (redo_state_list[0].id !== state.id + 1)
|
||||
redo_state_list = [];
|
||||
}
|
||||
|
||||
undo_state_list.push ( state );
|
||||
|
||||
_fireEvent ( 'StatePush', undo_state_list.length );
|
||||
_fireEvent ( 'DidStateChange', undo_state_list, redo_state_list);
|
||||
|
||||
return (true);
|
||||
};
|
||||
|
||||
q.popUndoState = function () {
|
||||
var last_state =undo_state_list.pop ();
|
||||
|
||||
if (last_state) {
|
||||
if (redo_state_list.length > 0)
|
||||
{
|
||||
if (redo_state_list[0].id !== last_state.id + 1)
|
||||
redo_state_list = [];
|
||||
}
|
||||
|
||||
var temp = app.engine.wavesurfer.backend.buffer;
|
||||
_fireEvent ( 'StateDidPop', last_state, 1 );
|
||||
|
||||
last_state.data = temp;
|
||||
redo_state_list.unshift (last_state);
|
||||
|
||||
_fireEvent ( 'DidStateChange', undo_state_list, redo_state_list);
|
||||
}
|
||||
|
||||
return (last_state);
|
||||
};
|
||||
|
||||
q.shiftRedoState = function () {
|
||||
var last_state = redo_state_list.shift ();
|
||||
|
||||
if (last_state) {
|
||||
if (undo_state_list.length > 0)
|
||||
{
|
||||
if (undo_state_list[undo_state_list.length - 1].id !== last_state.id - 1)
|
||||
undo_state_list = [];
|
||||
}
|
||||
|
||||
var temp = app.engine.wavesurfer.backend.buffer;
|
||||
_fireEvent ( 'StateDidPop', last_state, 0 );
|
||||
|
||||
last_state.data = temp;
|
||||
undo_state_list.push (last_state);
|
||||
|
||||
_fireEvent ( 'DidStateChange', undo_state_list, redo_state_list);
|
||||
}
|
||||
|
||||
return (last_state);
|
||||
};
|
||||
|
||||
q.clearAllState = function () {
|
||||
undo_state_list = [];
|
||||
redo_state_list = [];
|
||||
|
||||
_fireEvent ( 'StateClearAll' );
|
||||
_fireEvent ( 'DidStateChange', [], []);
|
||||
};
|
||||
|
||||
_listenFor ('StateRequestPush', function ( _state ) {
|
||||
q.pushUndoState ( _state );
|
||||
});
|
||||
_listenFor ('StateRequestUndo', function () {
|
||||
q.popUndoState ();
|
||||
});
|
||||
_listenFor ('StateRequestRedo', function () {
|
||||
q.shiftRedoState ();
|
||||
});
|
||||
_listenFor ('StateRequestClearAll', function () {
|
||||
q.clearAllState ();
|
||||
});
|
||||
_listenFor ('StateRequestLastState', function () {
|
||||
_fireEvent ('StateDidLastState', q.getLastUndoState ());
|
||||
});
|
||||
// -
|
||||
};
|
||||
|
||||
PKAE._deps.state = PKState;
|
||||
|
||||
})( PKAudioEditor );
|
||||
@@ -0,0 +1,59 @@
|
||||
const assets = [
|
||||
'./manifest.json',
|
||||
'./ico.png',
|
||||
'./icon.png',
|
||||
'./index.html',
|
||||
'./main.css',
|
||||
'./dist/wavesurfer.js',
|
||||
'./dist/plugin/wavesurfer.regions.js',
|
||||
'./oneup.js',
|
||||
'./app.js',
|
||||
'./keys.js',
|
||||
'./contextmenu.js',
|
||||
'./ui-fx.js',
|
||||
'./ui.js',
|
||||
'./modal.js',
|
||||
'./state.js',
|
||||
'./engine.js',
|
||||
'./actions.js',
|
||||
'./drag.js',
|
||||
'./recorder.js',
|
||||
'./welcome.js',
|
||||
'./fx-pg-eq.js',
|
||||
'./fx-auto.js',
|
||||
'./local.js',
|
||||
'./id3.js',
|
||||
'./lzma.js',
|
||||
'./lame.js',
|
||||
'./fonts/icomoon.ttf',
|
||||
'./fonts/icomoon.woff',
|
||||
'./favicon.ico',
|
||||
'./eq.html',
|
||||
'./sp.html',
|
||||
'./test.mp3'
|
||||
|
||||
];
|
||||
|
||||
self.addEventListener( 'install', async function () {
|
||||
const cache = await caches.open( 'audiomass' );
|
||||
assets.forEach( function ( asset ) {
|
||||
cache.add( asset ).catch( function () {
|
||||
console.error( '[SW] Cound\'t cache:', asset );
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
self.addEventListener( 'fetch', async function ( event ) {
|
||||
const request = event.request;
|
||||
event.respondWith( cacheFirst( request ) );
|
||||
});
|
||||
|
||||
async function cacheFirst( request ) {
|
||||
const cachedResponse = await caches.match( request );
|
||||
if ( cachedResponse === undefined ) {
|
||||
console.error( '[SW] Not cached:', request.url );
|
||||
return fetch( request );
|
||||
}
|
||||
|
||||
return cachedResponse;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
function interleave(inputL, inputR) {
|
||||
var length = inputL.length + inputR.length;
|
||||
var result = new Int16Array(length);
|
||||
|
||||
var index = 0,
|
||||
inputIndex = 0;
|
||||
|
||||
while (index < length) {
|
||||
result[index++] = inputL[inputIndex];
|
||||
result[index++] = inputR[inputIndex];
|
||||
++inputIndex;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function floatTo16BitPCM(output, offset, input) {
|
||||
for (var i = 0; i < input.length; i++, offset += 2) {
|
||||
output.setInt16(offset, input[i], true);
|
||||
}
|
||||
}
|
||||
|
||||
function writeString(view, offset, string) {
|
||||
for (var i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
}
|
||||
|
||||
function encodeWAV(samples, numChannels, sampleRate) {
|
||||
var buffer = new ArrayBuffer(44 + samples.length * 2);
|
||||
var view = new DataView(buffer);
|
||||
|
||||
/* RIFF identifier */
|
||||
writeString(view, 0, 'RIFF');
|
||||
/* RIFF chunk length */
|
||||
view.setUint32(4, 36 + samples.length * 2, true);
|
||||
/* RIFF type */
|
||||
writeString(view, 8, 'WAVE');
|
||||
/* format chunk identifier */
|
||||
writeString(view, 12, 'fmt ');
|
||||
/* format chunk length */
|
||||
view.setUint32(16, 16, true);
|
||||
/* sample format (raw) */
|
||||
view.setUint16(20, 1, true);
|
||||
/* channel count */
|
||||
view.setUint16(22, numChannels, true);
|
||||
/* sample rate */
|
||||
view.setUint32(24, sampleRate, true);
|
||||
/* byte rate (sample rate * block align) */
|
||||
view.setUint32(28, sampleRate * numChannels * 2, true);
|
||||
/* block align (channel count * bytes per sample) */
|
||||
view.setUint16(32, numChannels * 2, true);
|
||||
/* bits per sample */
|
||||
view.setUint16(34, 16, true);
|
||||
/* data chunk identifier */
|
||||
writeString(view, 36, 'data');
|
||||
/* data chunk length */
|
||||
view.setUint32(40, samples.length * 2, true);
|
||||
|
||||
floatTo16BitPCM(view, 44, samples);
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
|
||||
|
||||
var sample_rate = 44100;
|
||||
var kbps = 128;
|
||||
var channels = 1;
|
||||
var mp3encoder = null;
|
||||
|
||||
var samples_left = null;
|
||||
var samples_right = null;
|
||||
var first_buffer = true;
|
||||
|
||||
onmessage = function( ev ) {
|
||||
if (!ev.data) return ;
|
||||
|
||||
if (ev.data.sample_rate) {
|
||||
sample_rate = ev.data.sample_rate / 1;
|
||||
kbps = ev.data.kbps / 1;
|
||||
channels = ev.data.channels / 1;
|
||||
|
||||
return ;
|
||||
}
|
||||
|
||||
if (first_buffer) {
|
||||
samples_left = new Int16Array (ev.data, 0);
|
||||
first_buffer = false;
|
||||
|
||||
if (channels > 1) return ;
|
||||
}
|
||||
|
||||
if (ev.data && channels > 1) {
|
||||
samples_right = new Int16Array (ev.data, 0);
|
||||
}
|
||||
|
||||
///
|
||||
var interleaved = undefined;
|
||||
if (channels > 1) {
|
||||
interleaved = interleave(samples_left, samples_right);
|
||||
} else {
|
||||
interleaved = samples_left;
|
||||
}
|
||||
|
||||
var dataview = encodeWAV(interleaved, channels, sample_rate);
|
||||
var audioBlob = new Blob([dataview], { type: 'audio/wav' });
|
||||
|
||||
postMessage( audioBlob );
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
(function ( w, d, PKAE ) {
|
||||
'use strict';
|
||||
|
||||
setTimeout(function () {
|
||||
|
||||
PKAudioEditor._deps.Wlc = function () {
|
||||
var body_str = '';
|
||||
var body_str2 = '';
|
||||
|
||||
if (PKAE.isMobile) {
|
||||
change -= 15;
|
||||
body_str = 'Tips:<br/>Please make sure your device is not in silent mode. You might need to physically flip the silent switch. '+
|
||||
'<img src="phone-switch.jpg" style="max-width:224px;max-height:126px;width:40%;margin: 10px auto; display: block;"/>'+
|
||||
'<br/><br/>';
|
||||
}
|
||||
else {
|
||||
body_str = 'Tips:<br/>Please keep in mind that most key shortcuts rely on the <strong>Shift + <u>key</u></strong> combo. (eg Shift+Z for undo, Shift+C copy, Shift+X cut... etc )<br/><br/>';
|
||||
body_str2 = 'Check out the codebase on <a href="https://github.com/pkalogiros/audiomass" target="_blank">Github</a><br/><br/>'; // checkout the code on github
|
||||
}
|
||||
|
||||
// Welcome to AudioMass,
|
||||
var md = new PKSimpleModal({
|
||||
title: '<font style="font-size:15px">Welcome to AudioMass</font>',
|
||||
ondestroy: function( q ) {
|
||||
PKAE.ui.InteractionHandler.on = false;
|
||||
PKAE.ui.KeyHandler.removeCallback ('modalTemp');
|
||||
},
|
||||
body:'<div style="overflow:auto;-webkit-overflow-scrolling:touch;max-width:580px;width:calc(100vw - 40px);max-height:calc(100vh - 340px);min-height:110px;font-size:13px; color:#95c6c6;padding-top:7px;">'+
|
||||
'AudioMass is a free, open source, web-based Audio and Waveform Editor.<br />It runs entirely in the browser with no backend and no plugins required!'+
|
||||
'<br/><br/><br/>'+
|
||||
body_str+
|
||||
'You can load any type of audio your browser supports and perform operations such as fade in, cut, trim, change the volume, '+
|
||||
'and apply a plethora of audio effects.<br/><br/>'+
|
||||
body_str2+
|
||||
'</div>',
|
||||
setup:function( q ) {
|
||||
PKAE.ui.InteractionHandler.checkAndSet ('modal');
|
||||
PKAE.ui.KeyHandler.addCallback ('modalTemp', function ( e ) {
|
||||
q.Destroy ();
|
||||
}, [27]);
|
||||
|
||||
// ------
|
||||
var scroll = q.el_body.getElementsByTagName('div')[0];
|
||||
scroll.addEventListener ('touchstart', function(e){
|
||||
e.stopPropagation ();
|
||||
}, false);
|
||||
scroll.addEventListener ('touchmove', function(e){
|
||||
e.stopPropagation ();
|
||||
}, false);
|
||||
|
||||
// ------
|
||||
}
|
||||
});
|
||||
md.Show ();
|
||||
document.getElementsByClassName('pk_modal_cancel')[0].innerHTML = ' OK ';
|
||||
};
|
||||
|
||||
var change = 96;
|
||||
var exists = w.localStorage && w.localStorage.getItem ('k');
|
||||
|
||||
if (!exists) {
|
||||
change = 0;
|
||||
w.localStorage && w.localStorage.setItem ('k', 1);
|
||||
}
|
||||
|
||||
if ( ((Math.random () * 100) >> 0) < change) return ;
|
||||
PKAudioEditor._deps.Wlc ();
|
||||
|
||||
}, 320);
|
||||
|
||||
})( window, document, PKAudioEditor );
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "ace-step-ui-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"storage:test": "tsx src/scripts/test-storage.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/multer": "^2.0.0",
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^8.1.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^2.0.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"uuid": "^9.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/node-cron": "^3.0.11",
|
||||
"@types/uuid": "^9.0.7",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/**
|
||||
* Demucs Web - Stem Extraction for SunoAce
|
||||
*/
|
||||
import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.21.0/dist/ort.all.mjs';
|
||||
import { DemucsProcessor, CONSTANTS } from './src/index.js';
|
||||
|
||||
const { SAMPLE_RATE, TRAINING_SAMPLES, TRACKS, DEFAULT_MODEL_URL } = CONSTANTS;
|
||||
|
||||
const LOCAL_MODEL_URL = '../models/htdemucs_embedded.onnx';
|
||||
|
||||
let processor = null;
|
||||
let audioContext = null;
|
||||
let audioBuffer = null;
|
||||
let isProcessing = false;
|
||||
|
||||
// DOM elements
|
||||
const dropZone = document.getElementById('dropZone');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const processBtn = document.getElementById('processBtn');
|
||||
const progressFill = document.getElementById('progressFill');
|
||||
const status = document.getElementById('status');
|
||||
const results = document.getElementById('results');
|
||||
const trackList = document.getElementById('trackList');
|
||||
const backendBadge = document.getElementById('backendBadge');
|
||||
const audioFileName = document.getElementById('audioFileName');
|
||||
const statusDetail = document.getElementById('statusDetail');
|
||||
const statsRow = document.getElementById('statsRow');
|
||||
const statElapsed = document.getElementById('statElapsed');
|
||||
const statSegment = document.getElementById('statSegment');
|
||||
const statSpeed = document.getElementById('statSpeed');
|
||||
const statETA = document.getElementById('statETA');
|
||||
|
||||
let processStartTime = null;
|
||||
|
||||
function log(phase, message) {
|
||||
const now = new Date();
|
||||
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'text-zinc-400 py-1 border-b border-zinc-800/50 last:border-0';
|
||||
logLine.innerHTML = `<span class="text-emerald-400">[${timeStr}]</span> <span class="text-teal-400">[${phase}]</span> ${message}`;
|
||||
statusDetail.appendChild(logLine);
|
||||
statusDetail.scrollTop = statusDetail.scrollHeight;
|
||||
console.log(`[${phase}] ${message}`);
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (!isFinite(seconds) || seconds < 0) return '--:--';
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
let backend = 'wasm';
|
||||
|
||||
if ('gpu' in navigator) {
|
||||
try {
|
||||
const gpuAdapter = await navigator.gpu.requestAdapter();
|
||||
if (gpuAdapter) {
|
||||
backend = 'webgpu';
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('WebGPU not available:', e);
|
||||
}
|
||||
}
|
||||
|
||||
ort.env.wasm.numThreads = navigator.hardwareConcurrency || 4;
|
||||
|
||||
if (backend === 'webgpu') {
|
||||
ort.env.webgpu = ort.env.webgpu || {};
|
||||
ort.env.webgpu.powerPreference = 'high-performance';
|
||||
backendBadge.textContent = 'WebGPU (GPU)';
|
||||
backendBadge.className = 'badge badge-gpu';
|
||||
} else {
|
||||
const threads = navigator.hardwareConcurrency || 4;
|
||||
backendBadge.textContent = `WASM (${threads} threads)`;
|
||||
backendBadge.className = 'badge badge-cpu';
|
||||
}
|
||||
|
||||
processor = new DemucsProcessor({
|
||||
ort,
|
||||
onProgress: ({ progress, currentSegment, totalSegments }) => {
|
||||
progressFill.style.width = (5 + progress * 90) + '%';
|
||||
|
||||
const elapsed = (Date.now() - processStartTime) / 1000;
|
||||
statElapsed.textContent = formatTime(elapsed);
|
||||
statSegment.textContent = `${currentSegment}/${totalSegments}`;
|
||||
|
||||
if (currentSegment > 0 && audioBuffer) {
|
||||
const processedDuration = (currentSegment / totalSegments) * audioBuffer.duration;
|
||||
const speed = processedDuration / elapsed;
|
||||
statSpeed.textContent = speed.toFixed(2) + 'x';
|
||||
|
||||
const remainingSegments = totalSegments - currentSegment;
|
||||
const avgTimePerSegment = elapsed / currentSegment;
|
||||
const eta = remainingSegments * avgTimePerSegment;
|
||||
statETA.textContent = formatTime(eta);
|
||||
}
|
||||
},
|
||||
onLog: log,
|
||||
onDownloadProgress: (loaded, total) => {
|
||||
const percent = ((loaded / total) * 100).toFixed(1);
|
||||
const loadedMB = (loaded / 1024 / 1024).toFixed(1);
|
||||
const totalMB = (total / 1024 / 1024).toFixed(1);
|
||||
status.textContent = `Downloading model... ${loadedMB}MB / ${totalMB}MB (${percent}%)`;
|
||||
progressFill.style.width = (loaded / total * 100) + '%';
|
||||
}
|
||||
});
|
||||
|
||||
status.textContent = 'Loading AI model...';
|
||||
|
||||
try {
|
||||
try {
|
||||
status.textContent = 'Downloading model (~172MB)...';
|
||||
await processor.loadModel(DEFAULT_MODEL_URL);
|
||||
} catch {
|
||||
status.textContent = 'Loading local model...';
|
||||
await processor.loadModel(LOCAL_MODEL_URL);
|
||||
}
|
||||
status.textContent = 'Ready - Select an audio file';
|
||||
progressFill.style.width = '0%';
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed to load model: ' + e.message;
|
||||
console.error('Failed to load model:', e);
|
||||
}
|
||||
|
||||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||
sampleRate: SAMPLE_RATE
|
||||
});
|
||||
|
||||
// Check for audio URL parameter and auto-start
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const audioUrl = urlParams.get('audioUrl');
|
||||
if (audioUrl) {
|
||||
await loadAudioFromUrl(audioUrl);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAudioFromUrl(url) {
|
||||
try {
|
||||
status.textContent = 'Loading audio...';
|
||||
const fileName = decodeURIComponent(url.split('/').pop() || 'audio.mp3');
|
||||
audioFileName.textContent = fileName;
|
||||
|
||||
// Force fresh fetch to avoid 304 Not Modified with empty body
|
||||
const response = await fetch(url, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
|
||||
const duration = audioBuffer.duration.toFixed(1);
|
||||
status.textContent = `Loaded: ${duration}s - Starting extraction...`;
|
||||
processBtn.disabled = false;
|
||||
|
||||
// Auto-start extraction
|
||||
setTimeout(() => startProcessing(), 500);
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed to load audio: ' + e.message;
|
||||
console.error('Failed to load audio from URL:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Drag and drop handlers
|
||||
dropZone.addEventListener('click', () => fileInput.click());
|
||||
dropZone.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.add('border-emerald-500', 'bg-emerald-500/5');
|
||||
});
|
||||
dropZone.addEventListener('dragleave', () => {
|
||||
dropZone.classList.remove('border-emerald-500', 'bg-emerald-500/5');
|
||||
});
|
||||
dropZone.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
dropZone.classList.remove('border-emerald-500', 'bg-emerald-500/5');
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file && file.type.startsWith('audio/')) {
|
||||
handleFile(file);
|
||||
}
|
||||
});
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) handleFile(file);
|
||||
});
|
||||
|
||||
async function handleFile(file) {
|
||||
audioFileName.textContent = file.name;
|
||||
status.textContent = 'Reading audio...';
|
||||
|
||||
try {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
||||
const duration = audioBuffer.duration.toFixed(1);
|
||||
status.textContent = `Loaded: ${duration}s - Ready to extract`;
|
||||
processBtn.disabled = false;
|
||||
} catch (e) {
|
||||
status.textContent = 'Failed to read audio: ' + e.message;
|
||||
console.error('Failed to decode audio:', e);
|
||||
}
|
||||
}
|
||||
|
||||
processBtn.addEventListener('click', startProcessing);
|
||||
|
||||
async function startProcessing() {
|
||||
if (!audioBuffer || !processor || isProcessing) return;
|
||||
|
||||
isProcessing = true;
|
||||
processBtn.disabled = true;
|
||||
processBtn.textContent = 'Processing...';
|
||||
results.classList.remove('visible');
|
||||
processStartTime = Date.now();
|
||||
statusDetail.innerHTML = '';
|
||||
statusDetail.classList.add('visible');
|
||||
statsRow.classList.add('visible');
|
||||
|
||||
try {
|
||||
log('Init', 'Starting stem extraction...');
|
||||
status.textContent = 'Preparing audio...';
|
||||
progressFill.style.width = '2%';
|
||||
|
||||
let leftChannel = audioBuffer.getChannelData(0);
|
||||
let rightChannel = audioBuffer.numberOfChannels > 1
|
||||
? audioBuffer.getChannelData(1)
|
||||
: leftChannel;
|
||||
|
||||
if (audioBuffer.sampleRate !== SAMPLE_RATE) {
|
||||
log('Resample', `${audioBuffer.sampleRate}Hz → ${SAMPLE_RATE}Hz`);
|
||||
const ratio = SAMPLE_RATE / audioBuffer.sampleRate;
|
||||
const newLength = Math.floor(leftChannel.length * ratio);
|
||||
const newLeft = new Float32Array(newLength);
|
||||
const newRight = new Float32Array(newLength);
|
||||
|
||||
for (let i = 0; i < newLength; i++) {
|
||||
const srcIdx = i / ratio;
|
||||
const idx0 = Math.floor(srcIdx);
|
||||
const idx1 = Math.min(idx0 + 1, leftChannel.length - 1);
|
||||
const frac = srcIdx - idx0;
|
||||
newLeft[i] = leftChannel[idx0] * (1 - frac) + leftChannel[idx1] * frac;
|
||||
newRight[i] = rightChannel[idx0] * (1 - frac) + rightChannel[idx1] * frac;
|
||||
}
|
||||
|
||||
leftChannel = newLeft;
|
||||
rightChannel = newRight;
|
||||
}
|
||||
|
||||
status.textContent = 'Extracting stems...';
|
||||
const separatedTracks = await processor.separate(leftChannel, rightChannel);
|
||||
displayResults(separatedTracks);
|
||||
|
||||
const totalTime = ((Date.now() - processStartTime) / 1000).toFixed(1);
|
||||
const speedRatio = (audioBuffer.duration / parseFloat(totalTime)).toFixed(2);
|
||||
|
||||
log('Done', `Completed in ${totalTime}s (${speedRatio}x realtime)`);
|
||||
status.textContent = `Complete! Extracted 4 stems in ${totalTime}s`;
|
||||
progressFill.style.width = '100%';
|
||||
|
||||
} catch (e) {
|
||||
status.textContent = 'Processing failed: ' + e.message;
|
||||
console.error('Processing failed:', e);
|
||||
}
|
||||
|
||||
isProcessing = false;
|
||||
processBtn.disabled = false;
|
||||
processBtn.textContent = 'Extract Stems';
|
||||
}
|
||||
|
||||
// Store track URLs for download all feature
|
||||
let trackUrls = {};
|
||||
|
||||
function displayResults(tracks) {
|
||||
trackList.innerHTML = '';
|
||||
trackUrls = {};
|
||||
|
||||
const TRACK_CONFIG = {
|
||||
drums: { icon: '🥁', label: 'Drums' },
|
||||
bass: { icon: '🎸', label: 'Bass' },
|
||||
other: { icon: '🎹', label: 'Instrumental' },
|
||||
vocals: { icon: '🎤', label: 'Vocals' }
|
||||
};
|
||||
|
||||
for (const [name, track] of Object.entries(tracks)) {
|
||||
const config = TRACK_CONFIG[name] || { icon: '🎵', label: name };
|
||||
const trackBuffer = audioContext.createBuffer(2, track.left.length, SAMPLE_RATE);
|
||||
trackBuffer.getChannelData(0).set(track.left);
|
||||
trackBuffer.getChannelData(1).set(track.right);
|
||||
|
||||
const audioBlob = audioBufferToWav(trackBuffer);
|
||||
const audioUrl = URL.createObjectURL(audioBlob);
|
||||
const trackId = `track-${name}`;
|
||||
const fileName = config.label.toLowerCase();
|
||||
|
||||
// Store for download all
|
||||
trackUrls[fileName] = audioUrl;
|
||||
|
||||
const trackDiv = document.createElement('div');
|
||||
trackDiv.className = 'track';
|
||||
trackDiv.innerHTML = `
|
||||
<div class="track-row">
|
||||
<div class="track-info">
|
||||
<div class="track-icon ${name}">${config.icon}</div>
|
||||
<div>
|
||||
<div class="track-name">${config.label}</div>
|
||||
<div class="track-duration">${formatTime(trackBuffer.duration)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="track-player">
|
||||
<button id="play-${trackId}" class="play-btn" onclick="togglePlay('${trackId}')">
|
||||
<svg fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
|
||||
<div id="progress-bg-${trackId}" class="track-progress" onclick="seekTrack(event, '${trackId}')">
|
||||
<div id="progress-${trackId}" class="track-progress-fill ${name}"></div>
|
||||
</div>
|
||||
|
||||
<span id="time-${trackId}" class="track-time">0:00 / ${formatTime(trackBuffer.duration)}</span>
|
||||
</div>
|
||||
|
||||
<a href="${audioUrl}" download="${fileName}.wav" class="download-btn">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
WAV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<audio id="audio-${trackId}" src="${audioUrl}" preload="metadata"></audio>
|
||||
`;
|
||||
|
||||
trackList.appendChild(trackDiv);
|
||||
|
||||
const audio = document.getElementById(`audio-${trackId}`);
|
||||
audio.addEventListener('timeupdate', () => updateProgress(trackId, audio));
|
||||
audio.addEventListener('ended', () => resetPlayer(trackId));
|
||||
}
|
||||
|
||||
results.classList.add('visible');
|
||||
}
|
||||
|
||||
// Download all stems
|
||||
window.downloadAllStems = function() {
|
||||
const entries = Object.entries(trackUrls);
|
||||
let index = 0;
|
||||
|
||||
function downloadNext() {
|
||||
if (index >= entries.length) return;
|
||||
const [name, url] = entries[index];
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${name}.wav`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
index++;
|
||||
setTimeout(downloadNext, 500);
|
||||
}
|
||||
|
||||
downloadNext();
|
||||
};
|
||||
|
||||
// Player functions (global scope for onclick handlers)
|
||||
window.togglePlay = function(trackId) {
|
||||
const audio = document.getElementById(`audio-${trackId}`);
|
||||
const playBtn = document.getElementById(`play-${trackId}`);
|
||||
|
||||
// Pause all other tracks
|
||||
document.querySelectorAll('audio').forEach(a => {
|
||||
if (a.id !== `audio-${trackId}` && !a.paused) {
|
||||
a.pause();
|
||||
const otherId = a.id.replace('audio-', '');
|
||||
resetPlayer(otherId);
|
||||
}
|
||||
});
|
||||
|
||||
if (audio.paused) {
|
||||
audio.play();
|
||||
playBtn.innerHTML = `<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"><path d="M6 4h4v16H6zm8 0h4v16h-4z"/></svg>`;
|
||||
} else {
|
||||
audio.pause();
|
||||
playBtn.innerHTML = `<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
|
||||
}
|
||||
};
|
||||
|
||||
window.seekTrack = function(event, trackId) {
|
||||
const audio = document.getElementById(`audio-${trackId}`);
|
||||
const progressBg = document.getElementById(`progress-bg-${trackId}`);
|
||||
const rect = progressBg.getBoundingClientRect();
|
||||
const percent = (event.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = percent * audio.duration;
|
||||
};
|
||||
|
||||
function updateProgress(trackId, audio) {
|
||||
const progress = document.getElementById(`progress-${trackId}`);
|
||||
const timeDisplay = document.getElementById(`time-${trackId}`);
|
||||
const percent = (audio.currentTime / audio.duration) * 100;
|
||||
progress.style.width = `${percent}%`;
|
||||
timeDisplay.textContent = `${formatTime(audio.currentTime)} / ${formatTime(audio.duration)}`;
|
||||
}
|
||||
|
||||
function resetPlayer(trackId) {
|
||||
const playBtn = document.getElementById(`play-${trackId}`);
|
||||
const progress = document.getElementById(`progress-${trackId}`);
|
||||
playBtn.innerHTML = `<svg class="w-4 h-4 ml-0.5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>`;
|
||||
progress.style.width = '0%';
|
||||
}
|
||||
|
||||
function audioBufferToWav(buffer) {
|
||||
const numChannels = buffer.numberOfChannels;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
const bitDepth = 16;
|
||||
const bytesPerSample = bitDepth / 8;
|
||||
const blockAlign = numChannels * bytesPerSample;
|
||||
const samples = buffer.length;
|
||||
const dataSize = samples * blockAlign;
|
||||
const bufferSize = 44 + dataSize;
|
||||
|
||||
const arrayBuffer = new ArrayBuffer(bufferSize);
|
||||
const view = new DataView(arrayBuffer);
|
||||
|
||||
const writeString = (offset, string) => {
|
||||
for (let i = 0; i < string.length; i++) {
|
||||
view.setUint8(offset + i, string.charCodeAt(i));
|
||||
}
|
||||
};
|
||||
|
||||
writeString(0, 'RIFF');
|
||||
view.setUint32(4, bufferSize - 8, true);
|
||||
writeString(8, 'WAVE');
|
||||
writeString(12, 'fmt ');
|
||||
view.setUint32(16, 16, true);
|
||||
view.setUint16(20, 1, true);
|
||||
view.setUint16(22, numChannels, true);
|
||||
view.setUint32(24, sampleRate, true);
|
||||
view.setUint32(28, sampleRate * blockAlign, true);
|
||||
view.setUint16(32, blockAlign, true);
|
||||
view.setUint16(34, bitDepth, true);
|
||||
writeString(36, 'data');
|
||||
view.setUint32(40, dataSize, true);
|
||||
|
||||
const channels = [];
|
||||
for (let c = 0; c < numChannels; c++) {
|
||||
channels.push(buffer.getChannelData(c));
|
||||
}
|
||||
|
||||
let offset = 44;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
for (let c = 0; c < numChannels; c++) {
|
||||
const sample = Math.max(-1, Math.min(1, channels[c][i]));
|
||||
const intSample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF;
|
||||
view.setInt16(offset, intSample, true);
|
||||
offset += 2;
|
||||
}
|
||||
}
|
||||
|
||||
return new Blob([arrayBuffer], { type: 'audio/wav' });
|
||||
}
|
||||
|
||||
init();
|
||||
@@ -0,0 +1,389 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Extract Stems - ACE-Step UI</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--suno-bg: #09090b;
|
||||
--suno-panel: #121214;
|
||||
--suno-card: #18181b;
|
||||
--suno-hover: #27272a;
|
||||
--suno-border: #27272a;
|
||||
--zinc-400: #a1a1aa;
|
||||
--zinc-500: #71717a;
|
||||
--zinc-600: #52525b;
|
||||
--zinc-700: #3f3f46;
|
||||
--zinc-800: #27272a;
|
||||
--emerald-400: #34d399;
|
||||
--emerald-500: #10b981;
|
||||
--emerald-600: #059669;
|
||||
--teal-400: #2dd4bf;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--suno-bg);
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: transparent; }
|
||||
::-webkit-scrollbar-thumb { background: var(--zinc-700); border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: var(--zinc-600); }
|
||||
|
||||
.container { max-width: 900px; margin: 0 auto; padding: 2rem 1rem; }
|
||||
|
||||
.header { text-align: center; margin-bottom: 2rem; }
|
||||
.header h1 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
background: linear-gradient(to right, var(--emerald-500), var(--teal-400));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
.header p { color: var(--zinc-500); font-size: 0.875rem; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.badge-detecting { background: var(--zinc-800); color: var(--zinc-400); }
|
||||
.badge-gpu { background: rgba(16, 185, 129, 0.2); color: var(--emerald-400); }
|
||||
.badge-cpu { background: rgba(245, 158, 11, 0.2); color: #fbbf24; }
|
||||
|
||||
.card {
|
||||
background: var(--suno-card);
|
||||
border: 1px solid var(--suno-border);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.drop-zone {
|
||||
border: 2px dashed var(--zinc-700);
|
||||
border-radius: 0.75rem;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.drop-zone:hover, .drop-zone.dragover {
|
||||
border-color: var(--emerald-500);
|
||||
background: rgba(16, 185, 129, 0.05);
|
||||
}
|
||||
.drop-zone-icon { font-size: 3rem; margin-bottom: 0.75rem; }
|
||||
.drop-zone p { color: var(--zinc-400); }
|
||||
.drop-zone .hint { color: var(--zinc-500); font-size: 0.875rem; margin-top: 0.25rem; }
|
||||
.file-name { margin-top: 1rem; color: var(--emerald-400); font-weight: 500; font-size: 0.875rem; }
|
||||
|
||||
input[type="file"] { display: none; }
|
||||
|
||||
.processing-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.processing-header h3 { font-weight: 600; }
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.btn-primary {
|
||||
background: var(--emerald-600);
|
||||
color: white;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) { background: var(--emerald-500); }
|
||||
.btn-primary:disabled {
|
||||
background: var(--zinc-700);
|
||||
color: var(--zinc-500);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 0.5rem;
|
||||
background: var(--zinc-800);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(to right, var(--emerald-500), var(--teal-400));
|
||||
transition: width 0.3s;
|
||||
width: 0%;
|
||||
}
|
||||
|
||||
.status { color: var(--zinc-400); font-size: 0.875rem; }
|
||||
|
||||
.stats-row {
|
||||
display: none;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--zinc-800);
|
||||
}
|
||||
.stats-row.visible { display: grid; }
|
||||
.stat-item { text-align: center; }
|
||||
.stat-value { font-size: 1.125rem; font-weight: 600; color: var(--emerald-400); }
|
||||
.stat-label { font-size: 0.75rem; color: var(--zinc-500); }
|
||||
|
||||
.log-detail {
|
||||
display: none;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 0.5rem;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.log-detail.visible { display: block; }
|
||||
.log-line { padding: 0.25rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); }
|
||||
.log-line:last-child { border-bottom: none; }
|
||||
.log-time { color: var(--emerald-400); margin-right: 0.5rem; }
|
||||
.log-phase { color: var(--teal-400); margin-right: 0.5rem; }
|
||||
|
||||
.results { display: none; }
|
||||
.results.visible { display: block; }
|
||||
.results-card {
|
||||
background: var(--suno-card);
|
||||
border: 1px solid var(--suno-border);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.results-header {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--zinc-800);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.results-header-text h3 { font-weight: 600; margin-bottom: 0.25rem; }
|
||||
.results-header-text p { color: var(--zinc-500); font-size: 0.75rem; }
|
||||
|
||||
.download-all-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--emerald-600);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.download-all-btn:hover { background: var(--emerald-500); }
|
||||
.download-all-btn svg { width: 16px; height: 16px; }
|
||||
|
||||
.track {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--zinc-800);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.track:last-child { border-bottom: none; }
|
||||
.track:hover { background: rgba(255,255,255,0.03); }
|
||||
|
||||
.track-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 140px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.track-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
.track-icon.drums { background: rgba(249, 115, 22, 0.1); border: 1px solid rgba(249, 115, 22, 0.2); }
|
||||
.track-icon.bass { background: rgba(168, 85, 247, 0.1); border: 1px solid rgba(168, 85, 247, 0.2); }
|
||||
.track-icon.other { background: rgba(59, 130, 246, 0.1); border: 1px solid rgba(59, 130, 246, 0.2); }
|
||||
.track-icon.vocals { background: rgba(236, 72, 153, 0.1); border: 1px solid rgba(236, 72, 153, 0.2); }
|
||||
|
||||
.track-name { font-weight: 600; }
|
||||
.track-duration { font-size: 0.75rem; color: var(--zinc-500); }
|
||||
|
||||
.track-player {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.play-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
color: black;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.play-btn:hover { transform: scale(1.05); }
|
||||
.play-btn svg { width: 14px; height: 14px; }
|
||||
|
||||
.track-progress {
|
||||
flex: 1;
|
||||
height: 6px;
|
||||
background: var(--zinc-700);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
.track-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
transition: width 0.1s;
|
||||
width: 0%;
|
||||
}
|
||||
.track-progress-fill.drums { background: linear-gradient(to right, #f97316, #f59e0b); }
|
||||
.track-progress-fill.bass { background: linear-gradient(to right, #a855f7, #8b5cf6); }
|
||||
.track-progress-fill.other { background: linear-gradient(to right, #3b82f6, #06b6d4); }
|
||||
.track-progress-fill.vocals { background: linear-gradient(to right, #ec4899, #f43f5e); }
|
||||
|
||||
.track-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--zinc-500);
|
||||
font-family: monospace;
|
||||
width: 90px;
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--zinc-800);
|
||||
color: var(--zinc-400);
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
transition: all 0.2s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.download-btn:hover { background: var(--zinc-700); color: white; }
|
||||
.download-btn svg { width: 14px; height: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Stem Extraction</h1>
|
||||
<p>AI-powered audio separation using Demucs</p>
|
||||
<span id="backendBadge" class="badge badge-detecting">Detecting...</span>
|
||||
</div>
|
||||
|
||||
<!-- Upload Card -->
|
||||
<div class="card">
|
||||
<div id="dropZone" class="drop-zone">
|
||||
<div class="drop-zone-icon">🎵</div>
|
||||
<p>Drop audio file here</p>
|
||||
<p class="hint">or click to select</p>
|
||||
<input type="file" id="fileInput" accept="audio/*">
|
||||
<p id="audioFileName" class="file-name"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing Card -->
|
||||
<div class="card">
|
||||
<div class="processing-header">
|
||||
<h3>Processing</h3>
|
||||
<button id="processBtn" class="btn btn-primary" disabled>Extract Stems</button>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div id="progressFill" class="progress-fill"></div>
|
||||
</div>
|
||||
<p id="status" class="status">Select an audio file to begin</p>
|
||||
|
||||
<div id="statsRow" class="stats-row">
|
||||
<div class="stat-item">
|
||||
<div id="statElapsed" class="stat-value">0:00</div>
|
||||
<div class="stat-label">Elapsed</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div id="statSegment" class="stat-value">0/0</div>
|
||||
<div class="stat-label">Segment</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div id="statSpeed" class="stat-value">-</div>
|
||||
<div class="stat-label">Speed</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div id="statETA" class="stat-value">--:--</div>
|
||||
<div class="stat-label">ETA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statusDetail" class="log-detail"></div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div id="results" class="results">
|
||||
<div class="results-card">
|
||||
<div class="results-header">
|
||||
<div class="results-header-text">
|
||||
<h3>Separated Tracks</h3>
|
||||
<p>Click to play, download individual stems</p>
|
||||
</div>
|
||||
<button class="download-all-btn" onclick="downloadAllStems()">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"/>
|
||||
</svg>
|
||||
Download All
|
||||
</button>
|
||||
</div>
|
||||
<div id="trackList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Constants for Demucs model
|
||||
*/
|
||||
export const CONSTANTS = {
|
||||
SAMPLE_RATE: 44100,
|
||||
FFT_SIZE: 4096,
|
||||
HOP_SIZE: 1024,
|
||||
TRAINING_SAMPLES: 343980,
|
||||
MODEL_SPEC_BINS: 2048,
|
||||
MODEL_SPEC_FRAMES: 336,
|
||||
SEGMENT_OVERLAP: 0.25,
|
||||
TRACKS: ['drums', 'bass', 'other', 'vocals'],
|
||||
|
||||
// Default model URL (Hugging Face Hub)
|
||||
DEFAULT_MODEL_URL: 'https://huggingface.co/timcsy/demucs-web-onnx/resolve/main/htdemucs_embedded.onnx'
|
||||
};
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Fast FFT/iFFT implementation using Cooley-Tukey radix-2 algorithm
|
||||
*/
|
||||
|
||||
const fftTwiddles = new Map();
|
||||
const ifftTwiddles = new Map();
|
||||
const hannWindows = new Map();
|
||||
|
||||
function getFFTTwiddles(n) {
|
||||
if (fftTwiddles.has(n)) return fftTwiddles.get(n);
|
||||
const real = new Float32Array(n / 2);
|
||||
const imag = new Float32Array(n / 2);
|
||||
for (let k = 0; k < n / 2; k++) {
|
||||
const angle = -2 * Math.PI * k / n;
|
||||
real[k] = Math.cos(angle);
|
||||
imag[k] = Math.sin(angle);
|
||||
}
|
||||
const twiddles = { real, imag };
|
||||
fftTwiddles.set(n, twiddles);
|
||||
return twiddles;
|
||||
}
|
||||
|
||||
function getIFFTTwiddles(n) {
|
||||
if (ifftTwiddles.has(n)) return ifftTwiddles.get(n);
|
||||
const real = new Float32Array(n / 2);
|
||||
const imag = new Float32Array(n / 2);
|
||||
for (let k = 0; k < n / 2; k++) {
|
||||
const angle = 2 * Math.PI * k / n;
|
||||
real[k] = Math.cos(angle);
|
||||
imag[k] = Math.sin(angle);
|
||||
}
|
||||
const twiddles = { real, imag };
|
||||
ifftTwiddles.set(n, twiddles);
|
||||
return twiddles;
|
||||
}
|
||||
|
||||
export function getHannWindow(size) {
|
||||
if (hannWindows.has(size)) return hannWindows.get(size);
|
||||
const window = new Float32Array(size);
|
||||
for (let i = 0; i < size; i++) {
|
||||
window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / size));
|
||||
}
|
||||
hannWindows.set(size, window);
|
||||
return window;
|
||||
}
|
||||
|
||||
function bitReverse(n, bits) {
|
||||
let result = 0;
|
||||
for (let i = 0; i < bits; i++) {
|
||||
result = (result << 1) | (n & 1);
|
||||
n >>= 1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function fft(realOut, imagOut, realIn, n) {
|
||||
const bits = Math.log2(n) | 0;
|
||||
const twiddles = getFFTTwiddles(n);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = bitReverse(i, bits);
|
||||
realOut[i] = realIn[j];
|
||||
imagOut[i] = 0;
|
||||
}
|
||||
|
||||
for (let size = 2; size <= n; size *= 2) {
|
||||
const halfSize = size / 2;
|
||||
const step = n / size;
|
||||
for (let i = 0; i < n; i += size) {
|
||||
for (let j = 0; j < halfSize; j++) {
|
||||
const k = j * step;
|
||||
const tReal = twiddles.real[k];
|
||||
const tImag = twiddles.imag[k];
|
||||
const idx1 = i + j;
|
||||
const idx2 = i + j + halfSize;
|
||||
const eReal = realOut[idx1];
|
||||
const eImag = imagOut[idx1];
|
||||
const oReal = realOut[idx2] * tReal - imagOut[idx2] * tImag;
|
||||
const oImag = realOut[idx2] * tImag + imagOut[idx2] * tReal;
|
||||
realOut[idx1] = eReal + oReal;
|
||||
imagOut[idx1] = eImag + oImag;
|
||||
realOut[idx2] = eReal - oReal;
|
||||
imagOut[idx2] = eImag - oImag;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function ifft(realOut, imagOut, realIn, imagIn, n) {
|
||||
const bits = Math.log2(n) | 0;
|
||||
const twiddles = getIFFTTwiddles(n);
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const j = bitReverse(i, bits);
|
||||
realOut[i] = realIn[j];
|
||||
imagOut[i] = imagIn[j];
|
||||
}
|
||||
|
||||
for (let size = 2; size <= n; size *= 2) {
|
||||
const halfSize = size / 2;
|
||||
const step = n / size;
|
||||
for (let i = 0; i < n; i += size) {
|
||||
for (let j = 0; j < halfSize; j++) {
|
||||
const k = j * step;
|
||||
const tReal = twiddles.real[k];
|
||||
const tImag = twiddles.imag[k];
|
||||
const idx1 = i + j;
|
||||
const idx2 = i + j + halfSize;
|
||||
const eReal = realOut[idx1];
|
||||
const eImag = imagOut[idx1];
|
||||
const oReal = realOut[idx2] * tReal - imagOut[idx2] * tImag;
|
||||
const oImag = realOut[idx2] * tImag + imagOut[idx2] * tReal;
|
||||
realOut[idx1] = eReal + oReal;
|
||||
imagOut[idx1] = eImag + oImag;
|
||||
realOut[idx2] = eReal - oReal;
|
||||
imagOut[idx2] = eImag - oImag;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
realOut[i] /= n;
|
||||
imagOut[i] /= n;
|
||||
}
|
||||
}
|
||||
|
||||
export function stft(signal, fftSize, hopSize) {
|
||||
const numFrames = Math.floor((signal.length - fftSize) / hopSize) + 1;
|
||||
const numBins = fftSize / 2 + 1;
|
||||
const window = getHannWindow(fftSize);
|
||||
const scale = 1.0 / Math.sqrt(fftSize);
|
||||
|
||||
const specReal = new Float32Array(numFrames * numBins);
|
||||
const specImag = new Float32Array(numFrames * numBins);
|
||||
const frameReal = new Float32Array(fftSize);
|
||||
const frameImag = new Float32Array(fftSize);
|
||||
const windowedFrame = new Float32Array(fftSize);
|
||||
|
||||
for (let frame = 0; frame < numFrames; frame++) {
|
||||
const start = frame * hopSize;
|
||||
for (let i = 0; i < fftSize; i++) {
|
||||
windowedFrame[i] = signal[start + i] * window[i];
|
||||
}
|
||||
fft(frameReal, frameImag, windowedFrame, fftSize);
|
||||
const outOffset = frame * numBins;
|
||||
for (let k = 0; k < numBins; k++) {
|
||||
specReal[outOffset + k] = frameReal[k] * scale;
|
||||
specImag[outOffset + k] = frameImag[k] * scale;
|
||||
}
|
||||
}
|
||||
|
||||
return { real: specReal, imag: specImag, numFrames, numBins };
|
||||
}
|
||||
|
||||
export function istft(specReal, specImag, numFrames, numBins, fftSize, hopSize, length) {
|
||||
const outputLength = length || (numFrames - 1) * hopSize + fftSize;
|
||||
const output = new Float32Array(outputLength);
|
||||
const windowSum = new Float32Array(outputLength);
|
||||
const window = getHannWindow(fftSize);
|
||||
const scale = Math.sqrt(fftSize);
|
||||
|
||||
const fullReal = new Float32Array(fftSize);
|
||||
const fullImag = new Float32Array(fftSize);
|
||||
const outReal = new Float32Array(fftSize);
|
||||
const outImag = new Float32Array(fftSize);
|
||||
|
||||
for (let frame = 0; frame < numFrames; frame++) {
|
||||
fullReal.fill(0);
|
||||
fullImag.fill(0);
|
||||
|
||||
for (let k = 0; k < numBins; k++) {
|
||||
fullReal[k] = specReal[frame * numBins + k];
|
||||
fullImag[k] = specImag[frame * numBins + k];
|
||||
}
|
||||
|
||||
for (let k = 1; k < numBins - 1; k++) {
|
||||
fullReal[fftSize - k] = fullReal[k];
|
||||
fullImag[fftSize - k] = -fullImag[k];
|
||||
}
|
||||
|
||||
ifft(outReal, outImag, fullReal, fullImag, fftSize);
|
||||
|
||||
const start = frame * hopSize;
|
||||
for (let i = 0; i < fftSize && start + i < outputLength; i++) {
|
||||
output[start + i] += outReal[i] * window[i] * scale;
|
||||
windowSum[start + i] += window[i] * window[i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < outputLength; i++) {
|
||||
if (windowSum[i] > 1e-8) {
|
||||
output[i] /= windowSum[i];
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function reflectPad(signal, padLeft, padRight) {
|
||||
const length = signal.length;
|
||||
const output = new Float32Array(padLeft + length + padRight);
|
||||
|
||||
for (let i = 0; i < padLeft; i++) {
|
||||
const srcIdx = Math.min(padLeft - i, length - 1);
|
||||
output[i] = signal[srcIdx];
|
||||
}
|
||||
|
||||
output.set(signal, padLeft);
|
||||
|
||||
for (let i = 0; i < padRight; i++) {
|
||||
const srcIdx = Math.max(0, length - 2 - i);
|
||||
output[padLeft + length + i] = signal[srcIdx];
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Demucs Web - Music Source Separation using ONNX Runtime Web
|
||||
* @module demucs-web
|
||||
*/
|
||||
|
||||
export { CONSTANTS } from './constants.js';
|
||||
export { fft, ifft, stft, istft, reflectPad, getHannWindow } from './fft.js';
|
||||
export { DemucsProcessor, standaloneMask, standaloneIspec, prepareModelInput } from './processor.js';
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Demucs audio processor - Core separation logic
|
||||
*/
|
||||
|
||||
import { CONSTANTS } from './constants.js';
|
||||
import { stft, istft, reflectPad } from './fft.js';
|
||||
|
||||
const { SAMPLE_RATE, FFT_SIZE, HOP_SIZE, TRAINING_SAMPLES, MODEL_SPEC_BINS, MODEL_SPEC_FRAMES, SEGMENT_OVERLAP, TRACKS } = CONSTANTS;
|
||||
|
||||
/**
|
||||
* Convert model frequency output to complex spectrogram per track
|
||||
*/
|
||||
export function standaloneMask(freqOutput) {
|
||||
const numTracks = 4;
|
||||
const numChannels = 4;
|
||||
const numBins = MODEL_SPEC_BINS;
|
||||
const numFrames = MODEL_SPEC_FRAMES;
|
||||
const result = [];
|
||||
|
||||
for (let t = 0; t < numTracks; t++) {
|
||||
const trackSpec = {
|
||||
leftReal: new Float32Array(numBins * numFrames),
|
||||
leftImag: new Float32Array(numBins * numFrames),
|
||||
rightReal: new Float32Array(numBins * numFrames),
|
||||
rightImag: new Float32Array(numBins * numFrames)
|
||||
};
|
||||
|
||||
for (let f = 0; f < numFrames; f++) {
|
||||
for (let b = 0; b < numBins; b++) {
|
||||
const baseIdx = t * numChannels * numBins * numFrames;
|
||||
const outIdx = b * numFrames + f;
|
||||
trackSpec.leftReal[outIdx] = freqOutput[baseIdx + 0 * numBins * numFrames + b * numFrames + f];
|
||||
trackSpec.leftImag[outIdx] = freqOutput[baseIdx + 1 * numBins * numFrames + b * numFrames + f];
|
||||
trackSpec.rightReal[outIdx] = freqOutput[baseIdx + 2 * numBins * numFrames + b * numFrames + f];
|
||||
trackSpec.rightImag[outIdx] = freqOutput[baseIdx + 3 * numBins * numFrames + b * numFrames + f];
|
||||
}
|
||||
}
|
||||
result.push(trackSpec);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert complex spectrogram back to time domain (iSTFT with proper offsets)
|
||||
*/
|
||||
export function standaloneIspec(trackSpec, targetLength) {
|
||||
const numBins = MODEL_SPEC_BINS;
|
||||
const numFrames = MODEL_SPEC_FRAMES;
|
||||
const hopLength = HOP_SIZE;
|
||||
const paddedBins = numBins + 1;
|
||||
const paddedFrames = numFrames + 4;
|
||||
|
||||
const padChannel = (real, imag) => {
|
||||
const paddedReal = new Float32Array(paddedFrames * paddedBins);
|
||||
const paddedImag = new Float32Array(paddedFrames * paddedBins);
|
||||
|
||||
for (let f = 0; f < numFrames; f++) {
|
||||
for (let b = 0; b < numBins; b++) {
|
||||
const srcIdx = b * numFrames + f;
|
||||
const dstFrame = f + 2;
|
||||
const dstIdx = dstFrame * paddedBins + b;
|
||||
paddedReal[dstIdx] = real[srcIdx];
|
||||
paddedImag[dstIdx] = imag[srcIdx];
|
||||
}
|
||||
}
|
||||
return { real: paddedReal, imag: paddedImag };
|
||||
};
|
||||
|
||||
const leftPadded = padChannel(trackSpec.leftReal, trackSpec.leftImag);
|
||||
const rightPadded = padChannel(trackSpec.rightReal, trackSpec.rightImag);
|
||||
|
||||
const centerPad = FFT_SIZE / 2;
|
||||
const pad = Math.floor(hopLength / 2) * 3;
|
||||
const istftLength = (paddedFrames - 1) * hopLength + FFT_SIZE;
|
||||
|
||||
const leftOut = istft(leftPadded.real, leftPadded.imag, paddedFrames, paddedBins, FFT_SIZE, hopLength, istftLength);
|
||||
const rightOut = istft(rightPadded.real, rightPadded.imag, paddedFrames, paddedBins, FFT_SIZE, hopLength, istftLength);
|
||||
|
||||
const totalOffset = centerPad + pad;
|
||||
const left = leftOut.subarray(totalOffset, totalOffset + targetLength);
|
||||
const right = rightOut.subarray(totalOffset, totalOffset + targetLength);
|
||||
|
||||
return { left: new Float32Array(left), right: new Float32Array(right) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare model input from stereo audio
|
||||
*/
|
||||
export function prepareModelInput(leftChannel, rightChannel) {
|
||||
const inputLength = TRAINING_SAMPLES;
|
||||
|
||||
const paddedLeft = new Float32Array(inputLength);
|
||||
const paddedRight = new Float32Array(inputLength);
|
||||
const copyLen = Math.min(leftChannel.length, inputLength);
|
||||
paddedLeft.set(leftChannel.subarray(0, copyLen));
|
||||
paddedRight.set(rightChannel.subarray(0, copyLen));
|
||||
|
||||
const le = Math.ceil(inputLength / HOP_SIZE);
|
||||
const pad = Math.floor(HOP_SIZE / 2) * 3;
|
||||
const padRight = pad + le * HOP_SIZE - inputLength;
|
||||
|
||||
const stftInputLeft = reflectPad(paddedLeft, pad, padRight);
|
||||
const stftInputRight = reflectPad(paddedRight, pad, padRight);
|
||||
|
||||
const centerPad = FFT_SIZE / 2;
|
||||
const centeredLeft = reflectPad(stftInputLeft, centerPad, centerPad);
|
||||
const centeredRight = reflectPad(stftInputRight, centerPad, centerPad);
|
||||
|
||||
const stftLeft = stft(centeredLeft, FFT_SIZE, HOP_SIZE);
|
||||
const stftRight = stft(centeredRight, FFT_SIZE, HOP_SIZE);
|
||||
|
||||
const numBins = MODEL_SPEC_BINS;
|
||||
const numFrames = MODEL_SPEC_FRAMES;
|
||||
const frameOffset = 2;
|
||||
|
||||
const magSpec = new Float32Array(4 * numBins * numFrames);
|
||||
|
||||
for (let f = 0; f < numFrames; f++) {
|
||||
const srcFrame = f + frameOffset;
|
||||
for (let b = 0; b < numBins; b++) {
|
||||
const srcIdx = srcFrame * stftLeft.numBins + b;
|
||||
magSpec[0 * numBins * numFrames + b * numFrames + f] = stftLeft.real[srcIdx];
|
||||
magSpec[1 * numBins * numFrames + b * numFrames + f] = stftLeft.imag[srcIdx];
|
||||
magSpec[2 * numBins * numFrames + b * numFrames + f] = stftRight.real[srcIdx];
|
||||
magSpec[3 * numBins * numFrames + b * numFrames + f] = stftRight.imag[srcIdx];
|
||||
}
|
||||
}
|
||||
|
||||
const waveform = new Float32Array(2 * inputLength);
|
||||
waveform.set(paddedLeft, 0);
|
||||
waveform.set(paddedRight, inputLength);
|
||||
|
||||
return { waveform, magSpec, numBins, numFrames, originalLength: leftChannel.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Demucs processor class
|
||||
*/
|
||||
export class DemucsProcessor {
|
||||
constructor(options = {}) {
|
||||
this.ort = options.ort || null;
|
||||
this.session = null;
|
||||
this.modelPath = options.modelPath || './htdemucs_embedded.onnx';
|
||||
this.sessionOptions = options.sessionOptions || {};
|
||||
this.onProgress = options.onProgress || (() => {});
|
||||
this.onLog = options.onLog || (() => {});
|
||||
this.onDownloadProgress = options.onDownloadProgress || (() => {});
|
||||
}
|
||||
|
||||
async loadModel(modelPathOrBuffer) {
|
||||
if (!this.ort) {
|
||||
throw new Error('ONNX Runtime not provided. Pass ort in constructor options.');
|
||||
}
|
||||
|
||||
this.onLog('model', 'Loading model...');
|
||||
|
||||
let modelBuffer;
|
||||
if (modelPathOrBuffer instanceof ArrayBuffer) {
|
||||
modelBuffer = modelPathOrBuffer;
|
||||
} else {
|
||||
const response = await fetch(modelPathOrBuffer || this.modelPath);
|
||||
|
||||
// Check if we can track progress
|
||||
const contentLength = response.headers.get('Content-Length');
|
||||
if (contentLength && response.body) {
|
||||
const totalSize = parseInt(contentLength, 10);
|
||||
const reader = response.body.getReader();
|
||||
const chunks = [];
|
||||
let loadedSize = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
loadedSize += value.length;
|
||||
this.onDownloadProgress(loadedSize, totalSize);
|
||||
}
|
||||
|
||||
// Combine chunks into single ArrayBuffer
|
||||
const combined = new Uint8Array(loadedSize);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
combined.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
modelBuffer = combined.buffer;
|
||||
} else {
|
||||
// Fallback: no progress tracking
|
||||
modelBuffer = await response.arrayBuffer();
|
||||
}
|
||||
}
|
||||
|
||||
const defaultSessionOptions = {
|
||||
executionProviders: ['webgpu', 'wasm'],
|
||||
graphOptimizationLevel: 'basic'
|
||||
};
|
||||
|
||||
this.session = await this.ort.InferenceSession.create(modelBuffer, {
|
||||
...defaultSessionOptions,
|
||||
...this.sessionOptions
|
||||
});
|
||||
|
||||
this.onLog('model', 'Model loaded successfully');
|
||||
return this.session;
|
||||
}
|
||||
|
||||
async separate(leftChannel, rightChannel) {
|
||||
if (!this.session) {
|
||||
throw new Error('Model not loaded. Call loadModel() first.');
|
||||
}
|
||||
|
||||
const totalSamples = leftChannel.length;
|
||||
const stride = Math.floor(TRAINING_SAMPLES * (1 - SEGMENT_OVERLAP));
|
||||
const numSegments = Math.ceil((totalSamples - TRAINING_SAMPLES) / stride) + 1;
|
||||
|
||||
const outputs = TRACKS.map(() => ({
|
||||
left: new Float32Array(totalSamples),
|
||||
right: new Float32Array(totalSamples)
|
||||
}));
|
||||
const weights = new Float32Array(totalSamples);
|
||||
|
||||
let segmentIdx = 0;
|
||||
|
||||
for (let start = 0; start < totalSamples; start += stride) {
|
||||
const end = Math.min(start + TRAINING_SAMPLES, totalSamples);
|
||||
const segmentLength = end - start;
|
||||
|
||||
const segLeft = new Float32Array(TRAINING_SAMPLES);
|
||||
const segRight = new Float32Array(TRAINING_SAMPLES);
|
||||
|
||||
for (let i = 0; i < segmentLength; i++) {
|
||||
segLeft[i] = leftChannel[start + i];
|
||||
segRight[i] = rightChannel[start + i];
|
||||
}
|
||||
|
||||
const input = prepareModelInput(segLeft, segRight);
|
||||
|
||||
const waveformTensor = new this.ort.Tensor('float32', input.waveform, [1, 2, TRAINING_SAMPLES]);
|
||||
const magSpecTensor = new this.ort.Tensor('float32', input.magSpec, [1, 4, MODEL_SPEC_BINS, MODEL_SPEC_FRAMES]);
|
||||
|
||||
const feeds = {};
|
||||
feeds[this.session.inputNames[0]] = waveformTensor;
|
||||
if (this.session.inputNames.length > 1) {
|
||||
feeds[this.session.inputNames[1]] = magSpecTensor;
|
||||
}
|
||||
|
||||
const inferResults = await this.session.run(feeds);
|
||||
|
||||
let timeData = null, timeShape = null;
|
||||
let freqData = null;
|
||||
|
||||
for (const name of this.session.outputNames) {
|
||||
const tensor = inferResults[name];
|
||||
if (tensor.dims.length === 4 && tensor.dims[2] === 2) {
|
||||
timeData = tensor.data;
|
||||
timeShape = tensor.dims;
|
||||
} else if (tensor.dims.length === 5 && tensor.dims[2] === 4) {
|
||||
freqData = tensor.data;
|
||||
}
|
||||
}
|
||||
|
||||
if (!timeData) {
|
||||
throw new Error('Could not find time-domain output tensor');
|
||||
}
|
||||
|
||||
let combinedOutputs = null;
|
||||
if (freqData) {
|
||||
const trackSpecs = standaloneMask(freqData);
|
||||
combinedOutputs = [];
|
||||
|
||||
for (let t = 0; t < 4; t++) {
|
||||
const freqOutput = standaloneIspec(trackSpecs[t], TRAINING_SAMPLES);
|
||||
const numChannels = timeShape[2];
|
||||
const samples = timeShape[3];
|
||||
const timeLeft = new Float32Array(samples);
|
||||
const timeRight = new Float32Array(samples);
|
||||
|
||||
for (let i = 0; i < samples; i++) {
|
||||
timeLeft[i] = timeData[t * numChannels * samples + 0 * samples + i];
|
||||
timeRight[i] = timeData[t * numChannels * samples + 1 * samples + i];
|
||||
}
|
||||
|
||||
const combined = {
|
||||
left: new Float32Array(samples),
|
||||
right: new Float32Array(samples)
|
||||
};
|
||||
for (let i = 0; i < samples; i++) {
|
||||
combined.left[i] = timeLeft[i] + (freqOutput.left[i] || 0);
|
||||
combined.right[i] = timeRight[i] + (freqOutput.right[i] || 0);
|
||||
}
|
||||
combinedOutputs.push(combined);
|
||||
}
|
||||
}
|
||||
|
||||
const numTracks = timeShape[1];
|
||||
const numChannels = timeShape[2];
|
||||
const samples = timeShape[3];
|
||||
|
||||
const overlapWindow = new Float32Array(segmentLength);
|
||||
for (let i = 0; i < segmentLength; i++) {
|
||||
const fadeIn = Math.min(i / (stride * 0.5), 1);
|
||||
const fadeOut = Math.min((segmentLength - i) / (stride * 0.5), 1);
|
||||
overlapWindow[i] = Math.min(fadeIn, fadeOut);
|
||||
}
|
||||
|
||||
for (let t = 0; t < numTracks; t++) {
|
||||
for (let i = 0; i < segmentLength && start + i < totalSamples; i++) {
|
||||
let leftVal, rightVal;
|
||||
if (combinedOutputs) {
|
||||
leftVal = combinedOutputs[t].left[i];
|
||||
rightVal = combinedOutputs[t].right[i];
|
||||
} else {
|
||||
const leftIdx = t * numChannels * samples + 0 * samples + i;
|
||||
const rightIdx = t * numChannels * samples + 1 * samples + i;
|
||||
leftVal = timeData[leftIdx];
|
||||
rightVal = timeData[rightIdx];
|
||||
}
|
||||
outputs[t].left[start + i] += leftVal * overlapWindow[i];
|
||||
outputs[t].right[start + i] += rightVal * overlapWindow[i];
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < segmentLength && start + i < totalSamples; i++) {
|
||||
weights[start + i] += overlapWindow[i];
|
||||
}
|
||||
|
||||
segmentIdx++;
|
||||
this.onProgress({
|
||||
progress: segmentIdx / numSegments,
|
||||
currentSegment: segmentIdx,
|
||||
totalSegments: numSegments
|
||||
});
|
||||
}
|
||||
|
||||
for (let t = 0; t < TRACKS.length; t++) {
|
||||
for (let i = 0; i < totalSamples; i++) {
|
||||
if (weights[i] > 0) {
|
||||
outputs[t].left[i] /= weights[i];
|
||||
outputs[t].right[i] /= weights[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
drums: outputs[0],
|
||||
bass: outputs[1],
|
||||
other: outputs[2],
|
||||
vocals: outputs[3]
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Format lyrics and style using the 5Hz LLM.
|
||||
|
||||
This script uses ACE-Step's format_sample to enhance user input with AI-generated
|
||||
music metadata (BPM, duration, key, time signature, enhanced description).
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Get ACE-Step path from environment or use default
|
||||
ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5')
|
||||
sys.path.insert(0, ACESTEP_PATH)
|
||||
|
||||
from acestep.llm_inference import LLMHandler
|
||||
from acestep.inference import format_sample
|
||||
|
||||
# Global handler
|
||||
_llm_handler = None
|
||||
|
||||
def get_llm_handler():
|
||||
global _llm_handler
|
||||
if _llm_handler is None:
|
||||
_llm_handler = LLMHandler()
|
||||
# Initialize the LLM with the 0.6B model (lighter on VRAM)
|
||||
checkpoint_dir = os.path.join(ACESTEP_PATH, "checkpoints")
|
||||
lm_model_path = "acestep-5Hz-lm-0.6B" # Use the smaller 0.6B model
|
||||
|
||||
status, success = _llm_handler.initialize(
|
||||
checkpoint_dir=checkpoint_dir,
|
||||
lm_model_path=lm_model_path,
|
||||
backend="pt", # Use PyTorch backend
|
||||
device="cuda",
|
||||
offload_to_cpu=True,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise RuntimeError(f"Failed to initialize LLM: {status}")
|
||||
|
||||
return _llm_handler
|
||||
|
||||
def format_input(
|
||||
caption: str,
|
||||
lyrics: str = "",
|
||||
bpm: int = 0,
|
||||
duration: int = 0,
|
||||
key_scale: str = "",
|
||||
time_signature: str = "",
|
||||
temperature: float = 0.85,
|
||||
top_k: int = 0,
|
||||
top_p: float = 0.9,
|
||||
):
|
||||
"""Format caption and lyrics using the LLM."""
|
||||
handler = get_llm_handler()
|
||||
|
||||
# Build user metadata for constrained decoding
|
||||
user_metadata = {}
|
||||
if bpm and bpm > 0:
|
||||
user_metadata['bpm'] = int(bpm)
|
||||
if duration and duration > 0:
|
||||
user_metadata['duration'] = int(duration)
|
||||
if key_scale and key_scale.strip():
|
||||
user_metadata['keyscale'] = key_scale.strip()
|
||||
if time_signature and time_signature.strip():
|
||||
user_metadata['timesignature'] = time_signature.strip()
|
||||
|
||||
user_metadata_to_pass = user_metadata if user_metadata else None
|
||||
top_k_value = None if not top_k or top_k == 0 else int(top_k)
|
||||
top_p_value = None if not top_p or top_p >= 1.0 else top_p
|
||||
|
||||
result = format_sample(
|
||||
llm_handler=handler,
|
||||
caption=caption,
|
||||
lyrics=lyrics,
|
||||
user_metadata=user_metadata_to_pass,
|
||||
temperature=temperature,
|
||||
top_k=top_k_value,
|
||||
top_p=top_p_value,
|
||||
use_constrained_decoding=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"success": result.success,
|
||||
"caption": result.caption,
|
||||
"lyrics": result.lyrics,
|
||||
"bpm": result.bpm,
|
||||
"duration": result.duration,
|
||||
"key_scale": result.keyscale,
|
||||
"language": result.language,
|
||||
"time_signature": result.timesignature,
|
||||
"status_message": result.status_message,
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Format lyrics and style using ACE-Step LLM")
|
||||
parser.add_argument("--caption", type=str, required=True, help="Style/caption description")
|
||||
parser.add_argument("--lyrics", type=str, default="", help="Lyrics text")
|
||||
parser.add_argument("--bpm", type=int, default=0, help="Optional BPM constraint")
|
||||
parser.add_argument("--duration", type=int, default=0, help="Optional duration constraint")
|
||||
parser.add_argument("--key-scale", type=str, default="", help="Optional key scale constraint")
|
||||
parser.add_argument("--time-signature", type=str, default="", help="Optional time signature constraint")
|
||||
parser.add_argument("--temperature", type=float, default=0.85, help="LLM temperature")
|
||||
parser.add_argument("--top-k", type=int, default=0, help="LLM top-k sampling")
|
||||
parser.add_argument("--top-p", type=float, default=0.9, help="LLM top-p sampling")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
result = format_input(
|
||||
caption=args.caption,
|
||||
lyrics=args.lyrics,
|
||||
bpm=args.bpm,
|
||||
duration=args.duration,
|
||||
key_scale=args.key_scale,
|
||||
time_signature=args.time_signature,
|
||||
temperature=args.temperature,
|
||||
top_k=args.top_k,
|
||||
top_p=args.top_p,
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
result["elapsed_seconds"] = elapsed
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
if result["success"]:
|
||||
print(f"Caption: {result['caption']}")
|
||||
print(f"Lyrics: {result['lyrics'][:100]}...")
|
||||
print(f"BPM: {result['bpm']}")
|
||||
print(f"Duration: {result['duration']}")
|
||||
print(f"Key: {result['key_scale']}")
|
||||
print(f"Time Signature: {result['time_signature']}")
|
||||
print(f"Language: {result['language']}")
|
||||
else:
|
||||
print(f"Error: {result['status_message']}")
|
||||
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
else:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Simple music generation script that works like the Gradio interface.
|
||||
|
||||
This is a wrapper script that calls ACE-Step without modifying the original repo.
|
||||
Supports all ACE-Step generation parameters.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Get ACE-Step path from environment or use default
|
||||
ACESTEP_PATH = os.environ.get('ACESTEP_PATH', '/home/ambsd/Desktop/aceui/ACE-Step-1.5')
|
||||
|
||||
# Add ACE-Step to path
|
||||
sys.path.insert(0, ACESTEP_PATH)
|
||||
|
||||
from acestep.handler import AceStepHandler
|
||||
from acestep.llm_inference import LLMHandler
|
||||
from acestep.inference import GenerationParams, GenerationConfig, generate_music
|
||||
|
||||
# Global handlers (initialized once)
|
||||
_handler = None
|
||||
_llm_handler = None
|
||||
|
||||
def get_handlers():
|
||||
global _handler, _llm_handler
|
||||
if _handler is None:
|
||||
_handler = AceStepHandler()
|
||||
_handler.initialize_service(
|
||||
project_root=ACESTEP_PATH,
|
||||
config_path="acestep-v15-turbo",
|
||||
device="cuda",
|
||||
offload_to_cpu=True, # For 12GB GPU
|
||||
)
|
||||
_llm_handler = LLMHandler() # Create but don't initialize (not enough VRAM)
|
||||
return _handler, _llm_handler
|
||||
|
||||
def generate(
|
||||
# Basic parameters
|
||||
prompt: str,
|
||||
lyrics: str = "",
|
||||
instrumental: bool = False,
|
||||
duration: int = 60,
|
||||
bpm: int = 0,
|
||||
key_scale: str = "",
|
||||
time_signature: str = "",
|
||||
vocal_language: str = "auto",
|
||||
|
||||
# Generation parameters
|
||||
infer_steps: int = 8,
|
||||
guidance_scale: float = 10.0,
|
||||
batch_size: int = 1,
|
||||
seed: int = -1,
|
||||
audio_format: str = "mp3",
|
||||
shift: float = 3.0,
|
||||
|
||||
# Task type parameters
|
||||
task_type: str = "text2music",
|
||||
reference_audio: str = None,
|
||||
src_audio: str = None,
|
||||
audio_codes: str = "",
|
||||
repainting_start: float = 0,
|
||||
repainting_end: float = -1,
|
||||
audio_cover_strength: float = 1.0,
|
||||
instruction: str = "",
|
||||
|
||||
# LM/CoT parameters
|
||||
thinking: bool = False,
|
||||
lm_temperature: float = 0.85,
|
||||
lm_cfg_scale: float = 2.0,
|
||||
lm_top_k: int = 0,
|
||||
lm_top_p: float = 0.9,
|
||||
lm_negative_prompt: str = "",
|
||||
use_cot_metas: bool = True,
|
||||
use_cot_caption: bool = True,
|
||||
use_cot_language: bool = True,
|
||||
|
||||
# Advanced parameters
|
||||
use_adg: bool = False,
|
||||
cfg_interval_start: float = 0.0,
|
||||
cfg_interval_end: float = 1.0,
|
||||
|
||||
# Output
|
||||
output_dir: str = None,
|
||||
):
|
||||
"""Generate music and return audio file paths."""
|
||||
handler, llm_handler = get_handlers()
|
||||
|
||||
if output_dir is None:
|
||||
output_dir = os.path.join(ACESTEP_PATH, "output")
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Build generation params
|
||||
params = GenerationParams(
|
||||
# Basic
|
||||
task_type=task_type,
|
||||
caption=prompt,
|
||||
lyrics=lyrics if lyrics and not instrumental else "",
|
||||
instrumental=instrumental,
|
||||
duration=float(duration) if duration > 0 else -1.0,
|
||||
bpm=bpm if bpm > 0 else None,
|
||||
keyscale=key_scale if key_scale else "",
|
||||
timesignature=time_signature if time_signature else "",
|
||||
vocal_language=vocal_language if vocal_language else "auto",
|
||||
|
||||
# Generation
|
||||
inference_steps=infer_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
seed=seed if seed >= 0 else -1,
|
||||
shift=shift,
|
||||
|
||||
# Task-specific
|
||||
reference_audio=reference_audio if reference_audio else None,
|
||||
src_audio=src_audio if src_audio else None,
|
||||
audio_codes=audio_codes if audio_codes else "",
|
||||
repainting_start=repainting_start,
|
||||
repainting_end=repainting_end,
|
||||
audio_cover_strength=audio_cover_strength,
|
||||
instruction=instruction if instruction else "Fill the audio semantic mask based on the given conditions:",
|
||||
|
||||
# LM/CoT
|
||||
thinking=thinking,
|
||||
lm_temperature=lm_temperature,
|
||||
lm_cfg_scale=lm_cfg_scale,
|
||||
lm_top_k=lm_top_k,
|
||||
lm_top_p=lm_top_p,
|
||||
lm_negative_prompt=lm_negative_prompt if lm_negative_prompt else "NO USER INPUT",
|
||||
use_cot_metas=use_cot_metas,
|
||||
use_cot_caption=use_cot_caption,
|
||||
use_cot_language=use_cot_language,
|
||||
|
||||
# Advanced
|
||||
use_adg=use_adg,
|
||||
cfg_interval_start=cfg_interval_start,
|
||||
cfg_interval_end=cfg_interval_end,
|
||||
)
|
||||
|
||||
# Build generation config
|
||||
config = GenerationConfig(
|
||||
batch_size=batch_size,
|
||||
audio_format=audio_format,
|
||||
use_random_seed=(seed < 0),
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
result = generate_music(handler, llm_handler, params, config, save_dir=output_dir)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Extract audio paths from result
|
||||
audio_paths = []
|
||||
if result.audios:
|
||||
for audio in result.audios:
|
||||
if isinstance(audio, dict) and audio.get("path"):
|
||||
audio_paths.append(audio["path"])
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"audio_paths": audio_paths,
|
||||
"elapsed_seconds": elapsed,
|
||||
"output_dir": output_dir,
|
||||
}
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate music with ACE-Step")
|
||||
|
||||
# Basic parameters
|
||||
parser.add_argument("--prompt", type=str, required=True, help="Music description")
|
||||
parser.add_argument("--lyrics", type=str, default="", help="Lyrics (optional)")
|
||||
parser.add_argument("--instrumental", action="store_true", help="Generate instrumental music")
|
||||
parser.add_argument("--duration", type=int, default=60, help="Duration in seconds (0 for auto)")
|
||||
parser.add_argument("--bpm", type=int, default=0, help="BPM (0 for auto)")
|
||||
parser.add_argument("--key-scale", type=str, default="", help="Key scale (e.g., 'C Major')")
|
||||
parser.add_argument("--time-signature", type=str, default="", help="Time signature (2, 3, 4, or 6)")
|
||||
parser.add_argument("--vocal-language", type=str, default="auto", help="Vocal language code")
|
||||
|
||||
# Generation parameters
|
||||
parser.add_argument("--infer-steps", type=int, default=8, help="Inference steps")
|
||||
parser.add_argument("--guidance-scale", type=float, default=10.0, help="Guidance scale")
|
||||
parser.add_argument("--batch-size", type=int, default=1, help="Batch size")
|
||||
parser.add_argument("--seed", type=int, default=-1, help="Random seed (-1 for random)")
|
||||
parser.add_argument("--audio-format", type=str, default="mp3", choices=["mp3", "flac", "wav"])
|
||||
parser.add_argument("--shift", type=float, default=3.0, help="Timestep shift factor")
|
||||
|
||||
# Task type parameters
|
||||
parser.add_argument("--task-type", type=str, default="text2music",
|
||||
choices=["text2music", "cover", "repaint", "lego", "extract", "complete"],
|
||||
help="Generation task type")
|
||||
parser.add_argument("--reference-audio", type=str, default=None, help="Reference audio path for style transfer")
|
||||
parser.add_argument("--src-audio", type=str, default=None, help="Source audio path for audio-to-audio")
|
||||
parser.add_argument("--audio-codes", type=str, default="", help="Audio semantic codes")
|
||||
parser.add_argument("--repainting-start", type=float, default=0, help="Repainting start time (seconds)")
|
||||
parser.add_argument("--repainting-end", type=float, default=-1, help="Repainting end time (seconds)")
|
||||
parser.add_argument("--audio-cover-strength", type=float, default=1.0, help="Reference audio strength (0-1)")
|
||||
parser.add_argument("--instruction", type=str, default="", help="Task instruction prompt")
|
||||
|
||||
# LM/CoT parameters
|
||||
parser.add_argument("--thinking", action="store_true", help="Enable Chain-of-Thought reasoning")
|
||||
parser.add_argument("--lm-temperature", type=float, default=0.85, help="LLM temperature")
|
||||
parser.add_argument("--lm-cfg-scale", type=float, default=2.0, help="LLM guidance scale")
|
||||
parser.add_argument("--lm-top-k", type=int, default=0, help="LLM top-k sampling")
|
||||
parser.add_argument("--lm-top-p", type=float, default=0.9, help="LLM top-p sampling")
|
||||
parser.add_argument("--lm-negative-prompt", type=str, default="", help="LLM negative prompt")
|
||||
parser.add_argument("--no-cot-metas", action="store_true", help="Disable CoT for metadata")
|
||||
parser.add_argument("--no-cot-caption", action="store_true", help="Disable CoT for caption")
|
||||
parser.add_argument("--no-cot-language", action="store_true", help="Disable CoT for language")
|
||||
|
||||
# Advanced parameters
|
||||
parser.add_argument("--use-adg", action="store_true", help="Use Adaptive Dual Guidance")
|
||||
parser.add_argument("--cfg-interval-start", type=float, default=0.0, help="CFG interval start")
|
||||
parser.add_argument("--cfg-interval-end", type=float, default=1.0, help="CFG interval end")
|
||||
|
||||
# Output
|
||||
parser.add_argument("--output-dir", type=str, default=None, help="Output directory")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = generate(
|
||||
# Basic
|
||||
prompt=args.prompt,
|
||||
lyrics=args.lyrics,
|
||||
instrumental=args.instrumental,
|
||||
duration=args.duration,
|
||||
bpm=args.bpm,
|
||||
key_scale=args.key_scale,
|
||||
time_signature=args.time_signature,
|
||||
vocal_language=args.vocal_language,
|
||||
|
||||
# Generation
|
||||
infer_steps=args.infer_steps,
|
||||
guidance_scale=args.guidance_scale,
|
||||
batch_size=args.batch_size,
|
||||
seed=args.seed,
|
||||
audio_format=args.audio_format,
|
||||
shift=args.shift,
|
||||
|
||||
# Task type
|
||||
task_type=args.task_type,
|
||||
reference_audio=args.reference_audio,
|
||||
src_audio=args.src_audio,
|
||||
audio_codes=args.audio_codes,
|
||||
repainting_start=args.repainting_start,
|
||||
repainting_end=args.repainting_end,
|
||||
audio_cover_strength=args.audio_cover_strength,
|
||||
instruction=args.instruction,
|
||||
|
||||
# LM/CoT
|
||||
thinking=args.thinking,
|
||||
lm_temperature=args.lm_temperature,
|
||||
lm_cfg_scale=args.lm_cfg_scale,
|
||||
lm_top_k=args.lm_top_k,
|
||||
lm_top_p=args.lm_top_p,
|
||||
lm_negative_prompt=args.lm_negative_prompt,
|
||||
use_cot_metas=not args.no_cot_metas,
|
||||
use_cot_caption=not args.no_cot_caption,
|
||||
use_cot_language=not args.no_cot_language,
|
||||
|
||||
# Advanced
|
||||
use_adg=args.use_adg,
|
||||
cfg_interval_start=args.cfg_interval_start,
|
||||
cfg_interval_end=args.cfg_interval_end,
|
||||
|
||||
# Output
|
||||
output_dir=args.output_dir,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result))
|
||||
else:
|
||||
print(f"Generated {len(result['audio_paths'])} audio files in {result['elapsed_seconds']:.1f}s:")
|
||||
for path in result['audio_paths']:
|
||||
print(f" {path}")
|
||||
except Exception as e:
|
||||
if args.json:
|
||||
print(json.dumps({"success": False, "error": str(e)}))
|
||||
else:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,43 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
|
||||
// SQLite database
|
||||
database: {
|
||||
path: process.env.DATABASE_PATH || path.join(__dirname, '../../data/acestep.db'),
|
||||
},
|
||||
|
||||
// ACE-Step API (local)
|
||||
acestep: {
|
||||
apiUrl: process.env.ACESTEP_API_URL || 'http://localhost:8001',
|
||||
},
|
||||
|
||||
// Pexels (optional - for video backgrounds)
|
||||
pexels: {
|
||||
apiKey: process.env.PEXELS_API_KEY || '',
|
||||
},
|
||||
|
||||
// Frontend URL
|
||||
frontendUrl: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||
|
||||
// Storage (local only)
|
||||
storage: {
|
||||
provider: 'local' as const,
|
||||
audioDir: process.env.AUDIO_DIR || path.join(__dirname, '../../public/audio'),
|
||||
},
|
||||
|
||||
// Simplified JWT (for local session, not critical security)
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'ace-step-ui-local-secret',
|
||||
expiresIn: '365d', // Long-lived for local app
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,164 @@
|
||||
import { db } from './pool.js';
|
||||
|
||||
const migrations = `
|
||||
-- Users table (simplified - no credits, no stripe, no tiers)
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
bio TEXT,
|
||||
avatar_url TEXT,
|
||||
banner_url TEXT,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Songs table
|
||||
CREATE TABLE IF NOT EXISTS songs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
lyrics TEXT,
|
||||
style TEXT,
|
||||
caption TEXT,
|
||||
cover_url TEXT,
|
||||
audio_url TEXT,
|
||||
duration INTEGER,
|
||||
bpm INTEGER,
|
||||
key_scale TEXT,
|
||||
time_signature TEXT,
|
||||
tags TEXT DEFAULT '[]',
|
||||
is_public INTEGER DEFAULT 0,
|
||||
is_featured INTEGER DEFAULT 0,
|
||||
like_count INTEGER DEFAULT 0,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
has_video INTEGER DEFAULT 0,
|
||||
video_url TEXT,
|
||||
generation_params TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Generation jobs table (simplified - no credit_reserved)
|
||||
CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
acestep_task_id TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
params TEXT,
|
||||
result TEXT,
|
||||
error TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Playlists table
|
||||
CREATE TABLE IF NOT EXISTS playlists (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
cover_url TEXT,
|
||||
is_public INTEGER DEFAULT 1,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Playlist songs junction table
|
||||
CREATE TABLE IF NOT EXISTS playlist_songs (
|
||||
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL DEFAULT 0,
|
||||
added_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (playlist_id, song_id)
|
||||
);
|
||||
|
||||
-- Liked songs table
|
||||
CREATE TABLE IF NOT EXISTS liked_songs (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
liked_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (user_id, song_id)
|
||||
);
|
||||
|
||||
-- Comments table
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id TEXT PRIMARY KEY,
|
||||
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
content TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Followers table
|
||||
CREATE TABLE IF NOT EXISTS followers (
|
||||
follower_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
following_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (follower_id, following_id),
|
||||
CHECK (follower_id != following_id)
|
||||
);
|
||||
|
||||
-- Reference tracks (uploaded audio for use as references)
|
||||
CREATE TABLE IF NOT EXISTS reference_tracks (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
storage_key TEXT NOT NULL,
|
||||
duration INTEGER,
|
||||
file_size_bytes INTEGER,
|
||||
tags TEXT DEFAULT '[]',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Contact submissions table
|
||||
CREATE TABLE IF NOT EXISTS contact_submissions (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
category TEXT DEFAULT 'general',
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Indexes for performance
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_user_id ON songs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_created_at ON songs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_is_public ON songs(is_public);
|
||||
CREATE INDEX IF NOT EXISTS idx_songs_is_featured ON songs(is_featured);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_user_id ON generation_jobs(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_status ON generation_jobs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_generation_jobs_created_at ON generation_jobs(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_playlists_user_id ON playlists(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_song_id ON comments(song_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_created_at ON comments(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_followers_follower ON followers(follower_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_followers_following ON followers(following_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reference_tracks_user_id ON reference_tracks(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_reference_tracks_created_at ON reference_tracks(created_at);
|
||||
`;
|
||||
|
||||
function migrate(): void {
|
||||
console.log('Running SQLite database migrations...');
|
||||
|
||||
try {
|
||||
// Execute the entire migration script at once
|
||||
db.exec(migrations);
|
||||
console.log('Migrations completed successfully!');
|
||||
} catch (error) {
|
||||
// Check if it's just "already exists" errors
|
||||
const errorMsg = String(error);
|
||||
if (errorMsg.includes('already exists')) {
|
||||
console.log('Tables already exist, migrations completed!');
|
||||
} else {
|
||||
console.error('Migration failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
migrate();
|
||||
@@ -0,0 +1,171 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(config.database.path);
|
||||
import { mkdirSync } from 'fs';
|
||||
try {
|
||||
mkdirSync(dataDir, { recursive: true });
|
||||
} catch {
|
||||
// Directory already exists
|
||||
}
|
||||
|
||||
const dbInstance = new Database(config.database.path);
|
||||
dbInstance.pragma('journal_mode = WAL');
|
||||
dbInstance.pragma('foreign_keys = ON');
|
||||
|
||||
export { dbInstance as db };
|
||||
|
||||
// Convert parameters to SQLite-compatible types
|
||||
function sanitizeParams(params?: unknown[]): unknown[] | undefined {
|
||||
if (!params) return params;
|
||||
return params.map(p => {
|
||||
// Convert undefined to null
|
||||
if (p === undefined) return null;
|
||||
// Convert boolean to integer (SQLite stores booleans as 0/1)
|
||||
if (typeof p === 'boolean') return p ? 1 : 0;
|
||||
// Convert arrays and objects to JSON strings
|
||||
if (Array.isArray(p) || (typeof p === 'object' && p !== null)) {
|
||||
return JSON.stringify(p);
|
||||
}
|
||||
return p;
|
||||
});
|
||||
}
|
||||
|
||||
// Query result type - use 'any' for rows to avoid strict typing issues
|
||||
interface QueryResult {
|
||||
rows: any[];
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
// Convert SQL and execute
|
||||
function executeQuery(sql: string, params?: unknown[], dbRef: Database.Database = dbInstance): QueryResult {
|
||||
// Sanitize parameters for SQLite
|
||||
const sanitizedParams = sanitizeParams(params);
|
||||
|
||||
// Convert PostgreSQL $1, $2 placeholders to SQLite ?
|
||||
let convertedSql = sql;
|
||||
if (sanitizedParams && sanitizedParams.length > 0) {
|
||||
// Replace $N with ?
|
||||
convertedSql = sql.replace(/\$(\d+)/g, '?');
|
||||
}
|
||||
|
||||
// Handle common PostgreSQL -> SQLite conversions
|
||||
convertedSql = convertedSql
|
||||
.replace(/ILIKE/gi, 'LIKE')
|
||||
.replace(/CURRENT_TIMESTAMP/gi, "datetime('now')")
|
||||
.replace(/COALESCE/gi, 'COALESCE')
|
||||
.replace(/::text/gi, '')
|
||||
.replace(/::integer/gi, '')
|
||||
.replace(/::boolean/gi, '')
|
||||
.replace(/GREATEST\(([^,]+),\s*(\d+)\)/gi, 'MAX($1, $2)');
|
||||
|
||||
// Auto-generate UUID for INSERT statements that need an id
|
||||
const insertMatch = convertedSql.match(/INSERT INTO (\w+)\s*\(([^)]+)\)/i);
|
||||
if (insertMatch) {
|
||||
const tableName = insertMatch[1];
|
||||
const columns = insertMatch[2].split(',').map(c => c.trim().toLowerCase());
|
||||
|
||||
// Tables that need auto-generated IDs
|
||||
const tablesNeedingId = ['users', 'songs', 'playlists', 'generation_jobs', 'comments', 'reference_tracks', 'contact_submissions'];
|
||||
|
||||
if (tablesNeedingId.includes(tableName.toLowerCase()) && !columns.includes('id')) {
|
||||
// Add id to the INSERT
|
||||
const newId = randomUUID();
|
||||
const updatedColumns = 'id, ' + insertMatch[2];
|
||||
const valuesMatch = convertedSql.match(/VALUES\s*\(([^)]+)\)/i);
|
||||
if (valuesMatch) {
|
||||
const updatedValues = `VALUES ('${newId}', ${valuesMatch[1]})`;
|
||||
convertedSql = convertedSql.replace(/\([^)]+\)\s*VALUES/i, `(${updatedColumns}) VALUES`);
|
||||
convertedSql = convertedSql.replace(/VALUES\s*\([^)]+\)/i, updatedValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Determine if it's a SELECT/returning query
|
||||
const isSelect = /^\s*(SELECT|RETURNING)/i.test(convertedSql) ||
|
||||
convertedSql.includes('RETURNING');
|
||||
|
||||
if (isSelect || convertedSql.includes('RETURNING')) {
|
||||
const stmt = dbRef.prepare(convertedSql);
|
||||
const rows = sanitizedParams ? stmt.all(...sanitizedParams) : stmt.all();
|
||||
return { rows, rowCount: rows.length };
|
||||
} else {
|
||||
const stmt = dbRef.prepare(convertedSql);
|
||||
const result = sanitizedParams ? stmt.run(...sanitizedParams) : stmt.run();
|
||||
return { rows: [], rowCount: result.changes };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('SQLite query error:', error);
|
||||
console.error('SQL:', convertedSql);
|
||||
console.error('Params:', sanitizedParams);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Client-like interface for transaction support
|
||||
class SqliteClient {
|
||||
private inTransaction = false;
|
||||
|
||||
async query(sql: string, params?: unknown[]): Promise<QueryResult> {
|
||||
return executeQuery(sql, params, dbInstance);
|
||||
}
|
||||
|
||||
release() {
|
||||
// No-op for SQLite - connection doesn't need to be released
|
||||
if (this.inTransaction) {
|
||||
// If released while in transaction, rollback
|
||||
try {
|
||||
dbInstance.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Ignore if no transaction
|
||||
}
|
||||
this.inTransaction = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for compatibility with existing code that expects pool-like interface
|
||||
export const pool = {
|
||||
query: async (sql: string, params?: unknown[]): Promise<QueryResult> => {
|
||||
return executeQuery(sql, params);
|
||||
},
|
||||
|
||||
// For transaction support (used by like endpoint)
|
||||
connect: async () => {
|
||||
const client = new SqliteClient();
|
||||
// Override query to handle BEGIN/COMMIT/ROLLBACK
|
||||
const originalQuery = client.query.bind(client);
|
||||
client.query = async (sql: string, params?: unknown[]) => {
|
||||
const upperSql = sql.trim().toUpperCase();
|
||||
if (upperSql === 'BEGIN') {
|
||||
dbInstance.exec('BEGIN IMMEDIATE');
|
||||
(client as any).inTransaction = true;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (upperSql === 'COMMIT') {
|
||||
dbInstance.exec('COMMIT');
|
||||
(client as any).inTransaction = false;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
if (upperSql === 'ROLLBACK') {
|
||||
dbInstance.exec('ROLLBACK');
|
||||
(client as any).inTransaction = false;
|
||||
return { rows: [], rowCount: 0 };
|
||||
}
|
||||
return originalQuery(sql, params);
|
||||
};
|
||||
return client;
|
||||
},
|
||||
|
||||
end: async () => {
|
||||
dbInstance.close();
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
import { db } from './pool.js';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
// UUID generation helper (SQLite doesn't have gen_random_uuid())
|
||||
export function generateUUID(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
// JSON helper for SQLite (serialize objects to strings)
|
||||
export function toJSON(obj: unknown): string {
|
||||
return JSON.stringify(obj);
|
||||
}
|
||||
|
||||
// JSON helper for SQLite (parse JSON strings)
|
||||
export function fromJSON<T>(str: string | null | undefined): T | null {
|
||||
if (!str) return null;
|
||||
try {
|
||||
return JSON.parse(str) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Array helper for SQLite (store arrays as JSON strings)
|
||||
export function toArray(arr: unknown[]): string {
|
||||
return JSON.stringify(arr || []);
|
||||
}
|
||||
|
||||
// Array helper for SQLite (parse array from JSON string)
|
||||
export function fromArray<T>(str: string | null | undefined): T[] {
|
||||
if (!str) return [];
|
||||
try {
|
||||
return JSON.parse(str) as T[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ISO date string helper
|
||||
export function toISODate(date?: Date | null): string | null {
|
||||
if (!date) return null;
|
||||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Parse ISO date string
|
||||
export function fromISODate(str: string | null | undefined): Date | null {
|
||||
if (!str) return null;
|
||||
return new Date(str);
|
||||
}
|
||||
|
||||
// Transaction helper
|
||||
export function transaction<T>(fn: () => T): T {
|
||||
return db.transaction(fn)();
|
||||
}
|
||||
|
||||
// Batch insert helper
|
||||
export function batchInsert(
|
||||
table: string,
|
||||
columns: string[],
|
||||
rows: unknown[][]
|
||||
): void {
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const stmt = db.prepare(
|
||||
`INSERT INTO ${table} (${columns.join(', ')}) VALUES (${placeholders})`
|
||||
);
|
||||
|
||||
const insertMany = db.transaction((items: unknown[][]) => {
|
||||
for (const row of items) {
|
||||
stmt.run(...row);
|
||||
}
|
||||
});
|
||||
|
||||
insertMany(rows);
|
||||
}
|
||||
|
||||
export { db };
|
||||
@@ -0,0 +1,440 @@
|
||||
import dotenv from 'dotenv';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
// Load .env from project root (parent of server directory)
|
||||
const __filename_init = fileURLToPath(import.meta.url);
|
||||
const __dirname_init = path.dirname(__filename_init);
|
||||
dotenv.config({ path: path.join(__dirname_init, '../../.env') });
|
||||
import cron from 'node-cron';
|
||||
import { config } from './config/index.js';
|
||||
import { runCleanupJob, cleanupDeletedSongs } from './services/cleanup.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
import authRoutes from './routes/auth.js';
|
||||
import songsRoutes from './routes/songs.js';
|
||||
import generateRoutes from './routes/generate.js';
|
||||
import usersRoutes from './routes/users.js';
|
||||
import playlistsRoutes from './routes/playlists.js';
|
||||
import contactRoutes from './routes/contact.js';
|
||||
import referenceTrackRoutes from './routes/referenceTrack.js';
|
||||
import { pool } from './db/pool.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Security headers
|
||||
app.use(helmet({
|
||||
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||
crossOriginEmbedderPolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: {
|
||||
defaultSrc: ["'self'"],
|
||||
baseUri: ["'self'"],
|
||||
fontSrc: ["'self'", 'https:', 'data:'],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'self'"],
|
||||
imgSrc: ["'self'", 'data:', 'https:'],
|
||||
objectSrc: ["'none'"],
|
||||
scriptSrc: ["'self'"],
|
||||
scriptSrcAttr: ["'none'"],
|
||||
styleSrc: ["'self'", 'https:', "'unsafe-inline'"],
|
||||
upgradeInsecureRequests: [],
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow requests with no origin (mobile apps, curl, etc.)
|
||||
if (!origin) return callback(null, true);
|
||||
// Allow localhost on any port in development
|
||||
if (config.nodeEnv === 'development' && origin.includes('localhost')) {
|
||||
return callback(null, true);
|
||||
}
|
||||
// Allow LAN IPs in development (192.168.x.x, 10.x.x.x, 172.16-31.x.x)
|
||||
if (config.nodeEnv === 'development') {
|
||||
const lanPattern = /^https?:\/\/(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[01])\.)/;
|
||||
if (lanPattern.test(origin)) {
|
||||
return callback(null, true);
|
||||
}
|
||||
}
|
||||
// Allow configured frontend URL
|
||||
if (origin === config.frontendUrl) {
|
||||
return callback(null, true);
|
||||
}
|
||||
callback(new Error('Not allowed by CORS'));
|
||||
},
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
// Serve static audio files
|
||||
app.use('/audio', express.static(path.join(__dirname, '../public/audio')));
|
||||
|
||||
// Audio Editor (AudioMass) - needs relaxed CSP for inline scripts and external images
|
||||
app.use('/editor', (req, res, next) => {
|
||||
res.setHeader('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; media-src 'self' blob: data: http://localhost:* https:; connect-src 'self' http://localhost:* https:; worker-src 'self' blob:");
|
||||
next();
|
||||
}, express.static(path.join(__dirname, '../audio-editor')));
|
||||
|
||||
// Demucs Web (Stem Extraction) - requires COOP/COEP headers for SharedArrayBuffer and relaxed CSP for ONNX runtime
|
||||
app.use('/demucs-web', (req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||
res.setHeader('Content-Security-Policy', [
|
||||
"default-src 'self'",
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: https://cdn.jsdelivr.net",
|
||||
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
||||
"font-src 'self' https://fonts.gstatic.com",
|
||||
"img-src 'self' data: https:",
|
||||
"media-src 'self' blob: data: http://localhost:* https:",
|
||||
"connect-src 'self' blob: http://localhost:* https://cdn.jsdelivr.net https://huggingface.co https://*.huggingface.co https://*.hf.co",
|
||||
"worker-src 'self' blob:",
|
||||
"child-src 'self' blob:"
|
||||
].join('; '));
|
||||
next();
|
||||
}, express.static(path.join(__dirname, '../public/demucs-web')));
|
||||
|
||||
// Health check
|
||||
app.get('/health', (_req, res) => {
|
||||
res.json({ status: 'ok', service: 'ACE-Step UI API' });
|
||||
});
|
||||
|
||||
// oEmbed endpoint for rich embeds
|
||||
app.get('/api/oembed', async (req, res) => {
|
||||
const url = req.query.url as string;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const match = url.match(/\/song\/([a-zA-Z0-9-]+)/);
|
||||
if (!match) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.style, s.cover_url, s.duration,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ? AND s.is_public = 1`,
|
||||
[match[1]]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const song = result.rows[0];
|
||||
res.json({
|
||||
version: '1.0',
|
||||
type: 'rich',
|
||||
provider_name: 'ACE-Step UI',
|
||||
provider_url: config.frontendUrl,
|
||||
title: song.title,
|
||||
author_name: song.creator,
|
||||
thumbnail_url: song.cover_url,
|
||||
thumbnail_width: 400,
|
||||
thumbnail_height: 400,
|
||||
html: `<iframe src="${config.frontendUrl}/embed/${song.id}" width="100%" height="152" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen></iframe>`,
|
||||
width: 400,
|
||||
height: 152
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('oEmbed error:', error);
|
||||
res.status(500).json({ error: 'Internal error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Song share page handler
|
||||
app.get('/song/:id', async (req, res) => {
|
||||
const songId = req.params.id;
|
||||
const userAgent = req.get('User-Agent') || '';
|
||||
|
||||
// Check if request is from a social media bot
|
||||
const isSocialBot = /twitterbot|facebookexternalhit|linkedinbot|slackbot|redditbot|discordbot|telegrambot|whatsapp|pinterestbot|tumblr|embedly|quora|outbrain|vkshare|w3c_validator|baiduspider|bingbot/i.test(userAgent);
|
||||
|
||||
if (!isSocialBot) {
|
||||
res.redirect(`${config.frontendUrl}?song=${songId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.style, s.cover_url, s.audio_url, s.duration, s.like_count, s.view_count,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = ? AND s.is_public = 1`,
|
||||
[songId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.redirect(config.frontendUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
const song = result.rows[0];
|
||||
const coverUrl = song.cover_url || `https://picsum.photos/seed/${song.id}/1200/630`;
|
||||
const title = `${song.title} by ${song.creator}`;
|
||||
const description = `🎵 ${song.style} • Create your own AI music free on ACE-Step UI`;
|
||||
const pageUrl = `${config.frontendUrl}/song/${song.id}`;
|
||||
|
||||
res.send(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${title} | ACE-Step UI</title>
|
||||
<meta name="title" content="${title}">
|
||||
<meta name="description" content="${description}">
|
||||
<meta property="og:type" content="music.song">
|
||||
<meta property="og:url" content="${pageUrl}">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${description}">
|
||||
<meta property="og:image" content="${coverUrl}">
|
||||
<meta property="og:site_name" content="ACE-Step UI">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${title}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${coverUrl}">
|
||||
<meta http-equiv="refresh" content="0;url=${config.frontendUrl}?song=${song.id}">
|
||||
</head>
|
||||
<body>
|
||||
<p>Redirecting to <a href="${config.frontendUrl}?song=${song.id}">ACE-Step UI</a>...</p>
|
||||
</body>
|
||||
</html>`);
|
||||
} catch (error) {
|
||||
console.error('Error serving song share page:', error);
|
||||
res.redirect(config.frontendUrl);
|
||||
}
|
||||
});
|
||||
|
||||
// Image proxy for CORS
|
||||
app.get('/api/proxy/image', async (req, res) => {
|
||||
const url = req.query.url as string;
|
||||
if (!url) {
|
||||
res.status(400).json({ error: 'URL required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
res.status(response.status).json({ error: 'Failed to fetch image' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg';
|
||||
const buffer = await response.arrayBuffer();
|
||||
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400');
|
||||
res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error('Image proxy error:', error);
|
||||
res.status(500).json({ error: 'Failed to proxy image' });
|
||||
}
|
||||
});
|
||||
|
||||
// Pexels API proxy - accepts API key from header or uses server config
|
||||
app.get('/api/pexels/photos', async (req, res) => {
|
||||
const query = req.query.query as string;
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Query required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept API key from header (user-provided) or fall back to server config
|
||||
const apiKey = req.headers['x-pexels-api-key'] as string || config.pexels.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({ error: 'Pexels API key not configured. Please set your API key in the Video Generator settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.pexels.com/v1/search?query=${encodeURIComponent(query)}&per_page=20&orientation=landscape`,
|
||||
{ headers: { Authorization: apiKey } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
res.status(401).json({ error: 'Invalid Pexels API key' });
|
||||
return;
|
||||
}
|
||||
res.status(response.status).json({ error: 'Pexels API error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Pexels photos error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch from Pexels' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/pexels/videos', async (req, res) => {
|
||||
const query = req.query.query as string;
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Query required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Accept API key from header (user-provided) or fall back to server config
|
||||
const apiKey = req.headers['x-pexels-api-key'] as string || config.pexels.apiKey;
|
||||
|
||||
if (!apiKey) {
|
||||
res.status(400).json({ error: 'Pexels API key not configured. Please set your API key in the Video Generator settings.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.pexels.com/videos/search?query=${encodeURIComponent(query)}&per_page=15&orientation=landscape`,
|
||||
{ headers: { Authorization: apiKey } }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
res.status(401).json({ error: 'Invalid Pexels API key' });
|
||||
return;
|
||||
}
|
||||
res.status(response.status).json({ error: 'Pexels API error' });
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
res.json(data);
|
||||
} catch (error) {
|
||||
console.error('Pexels videos error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch from Pexels' });
|
||||
}
|
||||
});
|
||||
|
||||
// Search endpoint
|
||||
app.get('/api/search', async (req, res) => {
|
||||
const query = (req.query.q as string)?.trim();
|
||||
const type = req.query.type as string;
|
||||
|
||||
if (!query) {
|
||||
res.status(400).json({ error: 'Search query required' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const searchPattern = `%${query}%`;
|
||||
const results: { songs: unknown[]; creators: unknown[]; playlists: unknown[] } = {
|
||||
songs: [],
|
||||
creators: [],
|
||||
playlists: [],
|
||||
};
|
||||
|
||||
if (!type || type === 'all' || type === 'songs') {
|
||||
const songsResult = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.tags, s.like_count, s.view_count, s.is_public, s.created_at,
|
||||
u.username as creator, u.avatar_url as creator_avatar
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.is_public = 1
|
||||
AND (s.title LIKE ? COLLATE NOCASE OR s.style LIKE ? COLLATE NOCASE)
|
||||
ORDER BY s.like_count DESC
|
||||
LIMIT 20`,
|
||||
[searchPattern, searchPattern]
|
||||
);
|
||||
results.songs = songsResult.rows;
|
||||
}
|
||||
|
||||
if (!type || type === 'all' || type === 'creators') {
|
||||
const creatorsResult = await pool.query(
|
||||
`SELECT u.id, u.username, u.bio, u.avatar_url, u.created_at,
|
||||
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) as follower_count
|
||||
FROM users u
|
||||
WHERE u.username LIKE ? COLLATE NOCASE
|
||||
ORDER BY (SELECT COUNT(*) FROM followers WHERE following_id = u.id) DESC
|
||||
LIMIT 20`,
|
||||
[searchPattern]
|
||||
);
|
||||
results.creators = creatorsResult.rows;
|
||||
}
|
||||
|
||||
if (!type || type === 'all' || type === 'playlists') {
|
||||
const playlistsResult = await pool.query(
|
||||
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
|
||||
u.username as creator, u.avatar_url as creator_avatar,
|
||||
(SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = p.id) as song_count
|
||||
FROM playlists p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.is_public = 1 AND p.name LIKE ? COLLATE NOCASE
|
||||
ORDER BY (SELECT COUNT(*) FROM playlist_songs ps WHERE ps.playlist_id = p.id) DESC
|
||||
LIMIT 20`,
|
||||
[searchPattern]
|
||||
);
|
||||
results.playlists = playlistsResult.rows;
|
||||
}
|
||||
|
||||
res.json(results);
|
||||
} catch (error) {
|
||||
console.error('Search error:', error);
|
||||
res.status(500).json({ error: 'Search failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/songs', songsRoutes);
|
||||
app.use('/api/generate', generateRoutes);
|
||||
app.use('/api/users', usersRoutes);
|
||||
app.use('/api/playlists', playlistsRoutes);
|
||||
app.use('/api/contact', contactRoutes);
|
||||
app.use('/api/reference-tracks', referenceTrackRoutes);
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||||
console.error('Unhandled error:', err);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
});
|
||||
|
||||
// Schedule cleanup job to run daily at 3 AM
|
||||
cron.schedule('0 3 * * *', async () => {
|
||||
console.log('Running scheduled cleanup job...');
|
||||
try {
|
||||
await runCleanupJob();
|
||||
await cleanupDeletedSongs();
|
||||
} catch (error) {
|
||||
console.error('Cleanup job failed:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Start server on all interfaces for LAN access
|
||||
app.listen(config.port, '0.0.0.0', () => {
|
||||
console.log(`ACE-Step UI Server running on http://localhost:${config.port}`);
|
||||
console.log(`Environment: ${config.nodeEnv}`);
|
||||
console.log(`ACE-Step API: ${config.acestep.apiUrl}`);
|
||||
|
||||
// Show LAN access info
|
||||
import('os').then(os => {
|
||||
const nets = os.networkInterfaces();
|
||||
for (const name of Object.keys(nets)) {
|
||||
for (const net of nets[name] || []) {
|
||||
if (net.family === 'IPv4' && !net.internal) {
|
||||
console.log(`LAN access: http://${net.address}:${config.port}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config/index.js';
|
||||
import { pool } from '../db/pool.js';
|
||||
|
||||
export interface AuthenticatedUser {
|
||||
id: string;
|
||||
username: string;
|
||||
isAdmin?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: AuthenticatedUser;
|
||||
}
|
||||
|
||||
export function authMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as AuthenticatedUser;
|
||||
req.user = decoded;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
|
||||
export function optionalAuthMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): void {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||
const token = authHeader.substring(7);
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as AuthenticatedUser;
|
||||
req.user = decoded;
|
||||
} catch {
|
||||
// Token invalid, but continue without user
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export async function adminMiddleware(
|
||||
req: AuthenticatedRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> {
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
res.status(401).json({ error: 'No token provided' });
|
||||
return;
|
||||
}
|
||||
|
||||
const token = authHeader.substring(7);
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwt.secret) as AuthenticatedUser;
|
||||
|
||||
const result = await pool.query(
|
||||
'SELECT is_admin FROM users WHERE id = ?',
|
||||
[decoded.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0 || !result.rows[0].is_admin) {
|
||||
res.status(403).json({ error: 'Admin access required' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.user = { ...decoded, isAdmin: true };
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { generateUUID } from '../db/sqlite.js';
|
||||
import { config } from '../config/index.js';
|
||||
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const jwtOptions = { expiresIn: config.jwt.expiresIn } as SignOptions;
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface SetupBody {
|
||||
username: string;
|
||||
}
|
||||
|
||||
function issueAccessToken(payload: { id: string; username: string }): string {
|
||||
return jwt.sign(payload, config.jwt.secret, jwtOptions);
|
||||
}
|
||||
|
||||
// Auto-login: Get the default user from database (for local single-user app)
|
||||
router.get('/auto', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
// Get the first user from the database (local app typically has one user)
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users ORDER BY created_at ASC LIMIT 1'
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
// No user exists yet - frontend should show username setup
|
||||
res.status(404).json({ error: 'No user found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Generate token for the user
|
||||
const token = issueAccessToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auto-login error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Setup or get user by username (simplified auth for local app)
|
||||
router.post('/setup', async (req: Request<object, object, SetupBody>, res: Response) => {
|
||||
try {
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
res.status(400).json({ error: 'Username is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize username
|
||||
const sanitizedUsername = username
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
.slice(0, 50);
|
||||
|
||||
if (sanitizedUsername.length < 2) {
|
||||
res.status(400).json({ error: 'Username must be at least 2 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user exists
|
||||
const existingUser = await pool.query(
|
||||
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE username = ?',
|
||||
[sanitizedUsername]
|
||||
);
|
||||
|
||||
let user;
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
// User exists, return it
|
||||
user = existingUser.rows[0];
|
||||
} else {
|
||||
// Create new user
|
||||
const userId = generateUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO users (id, username, is_admin, created_at, updated_at)
|
||||
VALUES (?, ?, 0, datetime('now'), datetime('now'))`,
|
||||
[userId, sanitizedUsername]
|
||||
);
|
||||
|
||||
const newUser = await pool.query(
|
||||
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
|
||||
[userId]
|
||||
);
|
||||
user = newUser.rows[0];
|
||||
}
|
||||
|
||||
// Generate token
|
||||
const token = issueAccessToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Auth setup error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get current user
|
||||
router.get('/me', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update username
|
||||
router.patch('/username', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username || typeof username !== 'string') {
|
||||
res.status(400).json({ error: 'Username is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Sanitize username
|
||||
const sanitizedUsername = username
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '')
|
||||
.slice(0, 50);
|
||||
|
||||
if (sanitizedUsername.length < 2) {
|
||||
res.status(400).json({ error: 'Username must be at least 2 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if username is taken by another user
|
||||
const existingUser = await pool.query(
|
||||
'SELECT id FROM users WHERE username = ? AND id != ?',
|
||||
[sanitizedUsername, req.user!.id]
|
||||
);
|
||||
|
||||
if (existingUser.rows.length > 0) {
|
||||
res.status(409).json({ error: 'Username is already taken' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Update username
|
||||
await pool.query(
|
||||
`UPDATE users SET username = ?, updated_at = datetime('now') WHERE id = ?`,
|
||||
[sanitizedUsername, req.user!.id]
|
||||
);
|
||||
|
||||
// Get updated user
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
// Issue new token with updated username
|
||||
const token = issueAccessToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update username error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Logout (no-op for local app, just for API compatibility)
|
||||
router.post('/logout', async (_req: Request, res: Response) => {
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Refresh token (for API compatibility - just returns current user if token valid)
|
||||
router.post('/refresh', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
'SELECT id, username, bio, avatar_url, banner_url, is_admin, created_at FROM users WHERE id = ?',
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
const token = issueAccessToken({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
bio: user.bio,
|
||||
avatar_url: user.avatar_url,
|
||||
banner_url: user.banner_url,
|
||||
isAdmin: Boolean(user.is_admin),
|
||||
createdAt: user.created_at,
|
||||
},
|
||||
token,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Refresh token error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { adminMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
interface ContactSubmission {
|
||||
name: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
category: 'general' | 'support' | 'business' | 'press' | 'legal';
|
||||
}
|
||||
|
||||
// Public endpoint - submit contact form
|
||||
router.post('/', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { name, email, subject, message, category } = req.body as ContactSubmission;
|
||||
|
||||
// Validate required fields
|
||||
if (!name || !email || !subject || !message) {
|
||||
res.status(400).json({ error: 'All fields are required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Basic email validation
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
res.status(400).json({ error: 'Invalid email address' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate message length
|
||||
if (message.length > 5000) {
|
||||
res.status(400).json({ error: 'Message too long (max 5000 characters)' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Create table if not exists
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS contact_submissions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name VARCHAR(255) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
subject VARCHAR(500) NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
category VARCHAR(50) DEFAULT 'general',
|
||||
is_read BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Insert submission
|
||||
const result = await pool.query(
|
||||
`INSERT INTO contact_submissions (name, email, subject, message, category)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id, created_at`,
|
||||
[name, email, subject, message, category || 'general']
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
message: 'Your message has been sent. We\'ll get back to you soon!',
|
||||
id: result.rows[0].id,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Contact submission error:', error);
|
||||
res.status(500).json({ error: 'Failed to send message. Please try again.' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint - get all contact submissions
|
||||
router.get('/admin', adminMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT id, name, email, subject, message, category, is_read, created_at
|
||||
FROM contact_submissions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`);
|
||||
|
||||
res.json({ submissions: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Get contacts error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint - mark as read/unread
|
||||
router.patch('/admin/:id/read', adminMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { isRead } = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE contact_submissions SET is_read = $1 WHERE id = $2 RETURNING is_read`,
|
||||
[isRead, id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Submission not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, isRead: result.rows[0].is_read });
|
||||
} catch (error) {
|
||||
console.error('Update contact error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint - delete submission
|
||||
router.delete('/admin/:id', adminMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
const result = await pool.query(
|
||||
`DELETE FROM contact_submissions WHERE id = $1 RETURNING id`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Submission not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete contact error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Admin endpoint - get unread count
|
||||
router.get('/admin/unread-count', adminMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(`
|
||||
SELECT COUNT(*) as count FROM contact_submissions WHERE is_read = FALSE
|
||||
`);
|
||||
|
||||
res.json({ count: parseInt(result.rows[0].count, 10) });
|
||||
} catch (error) {
|
||||
console.error('Get unread count error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,627 @@
|
||||
import { Router, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { generateUUID } from '../db/sqlite.js';
|
||||
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import {
|
||||
generateMusicViaAPI,
|
||||
getJobStatus,
|
||||
getAudioStream,
|
||||
discoverEndpoints,
|
||||
checkSpaceHealth,
|
||||
cleanupJob,
|
||||
getJobRawResponse,
|
||||
downloadAudioToBuffer,
|
||||
} from '../services/acestep.js';
|
||||
import { getStorageProvider } from '../services/storage/factory.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const audioUpload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowedTypes = [
|
||||
'audio/mpeg',
|
||||
'audio/wav',
|
||||
'audio/x-wav',
|
||||
'audio/flac',
|
||||
'audio/x-flac',
|
||||
'audio/mp4',
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/webm',
|
||||
];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only common audio formats are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
interface GenerateBody {
|
||||
// Mode
|
||||
customMode: boolean;
|
||||
|
||||
// Simple Mode
|
||||
songDescription?: string;
|
||||
|
||||
// Custom Mode
|
||||
lyrics: string;
|
||||
style: string;
|
||||
title: string;
|
||||
|
||||
// Common
|
||||
instrumental: boolean;
|
||||
vocalLanguage?: string;
|
||||
|
||||
// Music Parameters
|
||||
duration?: number;
|
||||
bpm?: number;
|
||||
keyScale?: string;
|
||||
timeSignature?: string;
|
||||
|
||||
// Generation Settings
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
batchSize?: number;
|
||||
randomSeed?: boolean;
|
||||
seed?: number;
|
||||
thinking?: boolean;
|
||||
audioFormat?: 'mp3' | 'flac';
|
||||
inferMethod?: 'ode' | 'sde';
|
||||
shift?: number;
|
||||
|
||||
// LM Parameters
|
||||
lmTemperature?: number;
|
||||
lmCfgScale?: number;
|
||||
lmTopK?: number;
|
||||
lmTopP?: number;
|
||||
lmNegativePrompt?: string;
|
||||
|
||||
// Expert Parameters
|
||||
referenceAudioUrl?: string;
|
||||
sourceAudioUrl?: string;
|
||||
audioCodes?: string;
|
||||
repaintingStart?: number;
|
||||
repaintingEnd?: number;
|
||||
instruction?: string;
|
||||
audioCoverStrength?: number;
|
||||
taskType?: string;
|
||||
useAdg?: boolean;
|
||||
cfgIntervalStart?: number;
|
||||
cfgIntervalEnd?: number;
|
||||
customTimesteps?: string;
|
||||
useCotMetas?: boolean;
|
||||
useCotCaption?: boolean;
|
||||
useCotLanguage?: boolean;
|
||||
autogen?: boolean;
|
||||
constrainedDecodingDebug?: boolean;
|
||||
allowLmBatch?: boolean;
|
||||
getScores?: boolean;
|
||||
getLrc?: boolean;
|
||||
scoreScale?: number;
|
||||
lmBatchChunkSize?: number;
|
||||
trackName?: string;
|
||||
completeTrackClasses?: string[];
|
||||
isFormatCaption?: boolean;
|
||||
}
|
||||
|
||||
router.post('/upload-audio', authMiddleware, audioUpload.single('audio'), async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Audio file is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const storage = getStorageProvider();
|
||||
const extFromName = path.extname(req.file.originalname || '').toLowerCase();
|
||||
const extFromType = (() => {
|
||||
switch (req.file.mimetype) {
|
||||
case 'audio/mpeg':
|
||||
return '.mp3';
|
||||
case 'audio/wav':
|
||||
case 'audio/x-wav':
|
||||
return '.wav';
|
||||
case 'audio/flac':
|
||||
case 'audio/x-flac':
|
||||
return '.flac';
|
||||
case 'audio/ogg':
|
||||
return '.ogg';
|
||||
case 'audio/mp4':
|
||||
case 'audio/aac':
|
||||
return '.m4a';
|
||||
case 'audio/webm':
|
||||
return '.webm';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
})();
|
||||
const ext = extFromName || extFromType || '.audio';
|
||||
const key = `references/${req.user!.id}/${Date.now()}-${generateUUID()}${ext}`;
|
||||
const storedKey = await storage.upload(key, req.file.buffer, req.file.mimetype);
|
||||
const publicUrl = storage.getPublicUrl(storedKey);
|
||||
|
||||
res.json({ url: publicUrl, key: storedKey });
|
||||
} catch (error) {
|
||||
console.error('Upload reference audio error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload audio' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
customMode,
|
||||
songDescription,
|
||||
lyrics,
|
||||
style,
|
||||
title,
|
||||
instrumental,
|
||||
vocalLanguage,
|
||||
duration,
|
||||
bpm,
|
||||
keyScale,
|
||||
timeSignature,
|
||||
inferenceSteps,
|
||||
guidanceScale,
|
||||
batchSize,
|
||||
randomSeed,
|
||||
seed,
|
||||
thinking,
|
||||
audioFormat,
|
||||
inferMethod,
|
||||
shift,
|
||||
lmTemperature,
|
||||
lmCfgScale,
|
||||
lmTopK,
|
||||
lmTopP,
|
||||
lmNegativePrompt,
|
||||
referenceAudioUrl,
|
||||
sourceAudioUrl,
|
||||
audioCodes,
|
||||
repaintingStart,
|
||||
repaintingEnd,
|
||||
instruction,
|
||||
audioCoverStrength,
|
||||
taskType,
|
||||
useAdg,
|
||||
cfgIntervalStart,
|
||||
cfgIntervalEnd,
|
||||
customTimesteps,
|
||||
useCotMetas,
|
||||
useCotCaption,
|
||||
useCotLanguage,
|
||||
autogen,
|
||||
constrainedDecodingDebug,
|
||||
allowLmBatch,
|
||||
getScores,
|
||||
getLrc,
|
||||
scoreScale,
|
||||
lmBatchChunkSize,
|
||||
trackName,
|
||||
completeTrackClasses,
|
||||
isFormatCaption,
|
||||
} = req.body as GenerateBody;
|
||||
|
||||
if (!customMode && !songDescription) {
|
||||
res.status(400).json({ error: 'Song description required for simple mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (customMode && !style && !lyrics) {
|
||||
res.status(400).json({ error: 'Style or lyrics required for custom mode' });
|
||||
return;
|
||||
}
|
||||
|
||||
const params = {
|
||||
customMode,
|
||||
songDescription,
|
||||
lyrics,
|
||||
style,
|
||||
title,
|
||||
instrumental,
|
||||
vocalLanguage,
|
||||
duration,
|
||||
bpm,
|
||||
keyScale,
|
||||
timeSignature,
|
||||
inferenceSteps,
|
||||
guidanceScale,
|
||||
batchSize,
|
||||
randomSeed,
|
||||
seed,
|
||||
thinking,
|
||||
audioFormat,
|
||||
inferMethod,
|
||||
shift,
|
||||
lmTemperature,
|
||||
lmCfgScale,
|
||||
lmTopK,
|
||||
lmTopP,
|
||||
lmNegativePrompt,
|
||||
referenceAudioUrl,
|
||||
sourceAudioUrl,
|
||||
audioCodes,
|
||||
repaintingStart,
|
||||
repaintingEnd,
|
||||
instruction,
|
||||
audioCoverStrength,
|
||||
taskType,
|
||||
useAdg,
|
||||
cfgIntervalStart,
|
||||
cfgIntervalEnd,
|
||||
customTimesteps,
|
||||
useCotMetas,
|
||||
useCotCaption,
|
||||
useCotLanguage,
|
||||
autogen,
|
||||
constrainedDecodingDebug,
|
||||
allowLmBatch,
|
||||
getScores,
|
||||
getLrc,
|
||||
scoreScale,
|
||||
lmBatchChunkSize,
|
||||
trackName,
|
||||
completeTrackClasses,
|
||||
isFormatCaption,
|
||||
};
|
||||
|
||||
// Create job record in database
|
||||
const localJobId = generateUUID();
|
||||
await pool.query(
|
||||
`INSERT INTO generation_jobs (id, user_id, status, params, created_at, updated_at)
|
||||
VALUES (?, ?, 'queued', ?, datetime('now'), datetime('now'))`,
|
||||
[localJobId, req.user!.id, JSON.stringify(params)]
|
||||
);
|
||||
|
||||
// Start generation
|
||||
const { jobId: hfJobId } = await generateMusicViaAPI(params);
|
||||
|
||||
// Update job with ACE-Step task ID
|
||||
await pool.query(
|
||||
`UPDATE generation_jobs SET acestep_task_id = ?, status = 'running', updated_at = datetime('now') WHERE id = ?`,
|
||||
[hfJobId, localJobId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
jobId: localJobId,
|
||||
status: 'queued',
|
||||
queuePosition: 1,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Generate error:', error);
|
||||
res.status(500).json({ error: (error as Error).message || 'Generation failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/status/:jobId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const jobResult = await pool.query(
|
||||
`SELECT id, user_id, acestep_task_id, status, params, result, error, created_at
|
||||
FROM generation_jobs
|
||||
WHERE id = ?`,
|
||||
[req.params.jobId]
|
||||
);
|
||||
|
||||
if (jobResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Job not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const job = jobResult.rows[0];
|
||||
|
||||
if (job.user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If job is still running, check ACE-Step status
|
||||
if (['pending', 'queued', 'running'].includes(job.status) && job.acestep_task_id) {
|
||||
try {
|
||||
const aceStatus = await getJobStatus(job.acestep_task_id);
|
||||
|
||||
if (aceStatus.status !== job.status) {
|
||||
let updateQuery = `UPDATE generation_jobs SET status = ?, updated_at = datetime('now')`;
|
||||
const updateParams: unknown[] = [aceStatus.status];
|
||||
|
||||
if (aceStatus.status === 'succeeded' && aceStatus.result) {
|
||||
updateQuery += `, result = ?`;
|
||||
updateParams.push(JSON.stringify(aceStatus.result));
|
||||
} else if (aceStatus.status === 'failed' && aceStatus.error) {
|
||||
updateQuery += `, error = ?`;
|
||||
updateParams.push(aceStatus.error);
|
||||
}
|
||||
|
||||
updateQuery += ` WHERE id = ?`;
|
||||
updateParams.push(req.params.jobId);
|
||||
|
||||
await pool.query(updateQuery, updateParams);
|
||||
|
||||
// If succeeded, create song records
|
||||
if (aceStatus.status === 'succeeded' && aceStatus.result) {
|
||||
const params = typeof job.params === 'string' ? JSON.parse(job.params) : job.params;
|
||||
const audioUrls = aceStatus.result.audioUrls.filter((url: string) =>
|
||||
url.endsWith('.mp3') || url.endsWith('.flac')
|
||||
);
|
||||
const localPaths: string[] = [];
|
||||
const storage = getStorageProvider();
|
||||
|
||||
for (let i = 0; i < audioUrls.length; i++) {
|
||||
const audioUrl = audioUrls[i];
|
||||
const variationSuffix = audioUrls.length > 1 ? ` (v${i + 1})` : '';
|
||||
const songTitle = (params.title || 'Untitled') + variationSuffix;
|
||||
|
||||
const songId = generateUUID();
|
||||
|
||||
try {
|
||||
const { buffer } = await downloadAudioToBuffer(audioUrl);
|
||||
const ext = audioUrl.includes('.flac') ? '.flac' : '.mp3';
|
||||
const storageKey = `${req.user!.id}/${songId}${ext}`;
|
||||
const storedPath = await storage.upload(storageKey, buffer, `audio/${ext.slice(1)}`);
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
|
||||
duration, bpm, key_scale, time_signature, tags, is_public, generation_params,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))`,
|
||||
[
|
||||
songId,
|
||||
req.user!.id,
|
||||
songTitle,
|
||||
params.instrumental ? '[Instrumental]' : params.lyrics,
|
||||
params.style,
|
||||
params.style,
|
||||
storedPath,
|
||||
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
|
||||
aceStatus.result.bpm || params.bpm,
|
||||
aceStatus.result.keyScale || params.keyScale,
|
||||
aceStatus.result.timeSignature || params.timeSignature,
|
||||
JSON.stringify([]),
|
||||
JSON.stringify(params),
|
||||
]
|
||||
);
|
||||
|
||||
localPaths.push(storedPath);
|
||||
} catch (downloadError) {
|
||||
console.error(`Failed to download audio ${i + 1}:`, downloadError);
|
||||
// Still create song record with remote URL
|
||||
await pool.query(
|
||||
`INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url,
|
||||
duration, bpm, key_scale, time_signature, tags, is_public, generation_params,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, datetime('now'), datetime('now'))`,
|
||||
[
|
||||
songId,
|
||||
req.user!.id,
|
||||
songTitle,
|
||||
params.instrumental ? '[Instrumental]' : params.lyrics,
|
||||
params.style,
|
||||
params.style,
|
||||
audioUrl,
|
||||
aceStatus.result.duration && aceStatus.result.duration > 0 ? aceStatus.result.duration : (params.duration && params.duration > 0 ? params.duration : 120),
|
||||
aceStatus.result.bpm || params.bpm,
|
||||
aceStatus.result.keyScale || params.keyScale,
|
||||
aceStatus.result.timeSignature || params.timeSignature,
|
||||
JSON.stringify([]),
|
||||
JSON.stringify(params),
|
||||
]
|
||||
);
|
||||
localPaths.push(audioUrl);
|
||||
}
|
||||
}
|
||||
|
||||
aceStatus.result.audioUrls = localPaths;
|
||||
cleanupJob(job.acestep_task_id);
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
jobId: req.params.jobId,
|
||||
status: aceStatus.status,
|
||||
queuePosition: aceStatus.queuePosition,
|
||||
etaSeconds: aceStatus.etaSeconds,
|
||||
result: aceStatus.result,
|
||||
error: aceStatus.error,
|
||||
});
|
||||
return;
|
||||
} catch (aceError) {
|
||||
console.error('ACE-Step status check error:', aceError);
|
||||
}
|
||||
}
|
||||
|
||||
// Return stored status
|
||||
res.json({
|
||||
jobId: req.params.jobId,
|
||||
status: job.status,
|
||||
result: job.result && typeof job.result === 'string' ? JSON.parse(job.result) : job.result,
|
||||
error: job.error,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Status check error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Audio proxy endpoint
|
||||
router.get('/audio', async (req, res: Response) => {
|
||||
try {
|
||||
const audioPath = req.query.path as string;
|
||||
if (!audioPath) {
|
||||
res.status(400).json({ error: 'Path required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const audioResponse = await getAudioStream(audioPath);
|
||||
|
||||
if (!audioResponse.ok) {
|
||||
res.status(audioResponse.status).json({ error: 'Failed to fetch audio' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = audioResponse.headers.get('content-type');
|
||||
if (contentType) {
|
||||
res.setHeader('Content-Type', contentType);
|
||||
}
|
||||
|
||||
const contentLength = audioResponse.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
res.setHeader('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
const reader = audioResponse.body?.getReader();
|
||||
if (!reader) {
|
||||
res.status(500).json({ error: 'Failed to read audio stream' });
|
||||
return;
|
||||
}
|
||||
|
||||
const pump = async (): Promise<void> => {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
res.write(value);
|
||||
return pump();
|
||||
};
|
||||
|
||||
await pump();
|
||||
} catch (error) {
|
||||
console.error('Audio proxy error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/history', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, acestep_task_id, status, params, result, error, created_at
|
||||
FROM generation_jobs
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
res.json({ jobs: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Get history error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/endpoints', authMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const endpoints = await discoverEndpoints();
|
||||
res.json({ endpoints });
|
||||
} catch (error) {
|
||||
console.error('Discover endpoints error:', error);
|
||||
res.status(500).json({ error: 'Failed to discover endpoints' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/health', async (_req, res: Response) => {
|
||||
try {
|
||||
const healthy = await checkSpaceHealth();
|
||||
res.json({ healthy });
|
||||
} catch (error) {
|
||||
res.json({ healthy: false, error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/debug/:taskId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const rawResponse = getJobRawResponse(req.params.taskId);
|
||||
if (!rawResponse) {
|
||||
res.status(404).json({ error: 'Job not found or no raw response available' });
|
||||
return;
|
||||
}
|
||||
res.json({ rawResponse });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// Format endpoint - uses LLM to enhance style/lyrics
|
||||
router.post('/format', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { caption, lyrics, bpm, duration, keyScale, timeSignature, temperature, topK, topP } = req.body;
|
||||
|
||||
if (!caption) {
|
||||
res.status(400).json({ error: 'Caption/style is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { spawn } = await import('child_process');
|
||||
|
||||
const ACESTEP_DIR = process.env.ACESTEP_PATH || '/home/ambsd/Desktop/aceui/ACE-Step-1.5';
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
||||
const FORMAT_SCRIPT = path.join(SCRIPTS_DIR, 'format_sample.py');
|
||||
const pythonPath = path.join(ACESTEP_DIR, '.venv', 'bin', 'python');
|
||||
|
||||
const args = [
|
||||
FORMAT_SCRIPT,
|
||||
'--caption', caption,
|
||||
'--json',
|
||||
];
|
||||
|
||||
if (lyrics) args.push('--lyrics', lyrics);
|
||||
if (bpm && bpm > 0) args.push('--bpm', String(bpm));
|
||||
if (duration && duration > 0) args.push('--duration', String(duration));
|
||||
if (keyScale) args.push('--key-scale', keyScale);
|
||||
if (timeSignature) args.push('--time-signature', timeSignature);
|
||||
if (temperature !== undefined) args.push('--temperature', String(temperature));
|
||||
if (topK && topK > 0) args.push('--top-k', String(topK));
|
||||
if (topP !== undefined) args.push('--top-p', String(topP));
|
||||
|
||||
const result = await new Promise<{ success: boolean; data?: any; error?: string }>((resolve) => {
|
||||
const proc = spawn(pythonPath, args, {
|
||||
cwd: ACESTEP_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
CUDA_VISIBLE_DEVICES: '0',
|
||||
ACESTEP_PATH: ACESTEP_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0 && stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
resolve({ success: true, data: parsed });
|
||||
} catch {
|
||||
resolve({ success: false, error: 'Failed to parse format result' });
|
||||
}
|
||||
} else {
|
||||
resolve({ success: false, error: stderr || 'Format failed' });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
|
||||
if (result.success && result.data) {
|
||||
res.json(result.data);
|
||||
} else {
|
||||
res.status(500).json({ success: false, error: result.error });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Format error:', error);
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,257 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authMiddleware, optionalAuthMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { getStorageProvider } from '../services/storage/factory.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function resolveAccessibleAudioUrl(audioUrl: string | null, isPublic: boolean): Promise<string | null> {
|
||||
if (!audioUrl) return null;
|
||||
if (audioUrl.startsWith('s3://')) {
|
||||
const storageKey = audioUrl.replace('s3://', '');
|
||||
const storage = getStorageProvider();
|
||||
return isPublic ? storage.getPublicUrl(storageKey) : storage.getUrl(storageKey, 3600);
|
||||
}
|
||||
return audioUrl;
|
||||
}
|
||||
|
||||
// Create playlist
|
||||
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { name, description, isPublic, coverUrl } = req.body;
|
||||
|
||||
if (!name) {
|
||||
res.status(400).json({ error: 'Name is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO playlists (user_id, name, description, is_public, cover_url)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[req.user!.id, name, description, isPublic || false, coverUrl]
|
||||
);
|
||||
|
||||
res.status(201).json({ playlist: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Create playlist error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get my playlists
|
||||
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT p.*, COUNT(ps.song_id) as song_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_songs ps ON p.id = ps.playlist_id
|
||||
WHERE p.user_id = $1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC`,
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
res.json({ playlists: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Get playlists error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get featured public playlists (for search/explore page)
|
||||
router.get('/public/featured', async (_req, res: Response) => {
|
||||
try {
|
||||
// First try to get playlists with songs
|
||||
let result = await pool.query(
|
||||
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
|
||||
u.username as creator, u.avatar_url as creator_avatar,
|
||||
COUNT(ps.song_id) as song_count
|
||||
FROM playlists p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
LEFT JOIN playlist_songs ps ON p.id = ps.playlist_id
|
||||
WHERE p.is_public = true
|
||||
GROUP BY p.id, u.username, u.avatar_url
|
||||
HAVING COUNT(ps.song_id) > 0
|
||||
ORDER BY COUNT(ps.song_id) DESC
|
||||
LIMIT 20`
|
||||
);
|
||||
|
||||
// Fallback: if no playlists with songs, get any public playlists
|
||||
if (result.rows.length === 0) {
|
||||
result = await pool.query(
|
||||
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
|
||||
u.username as creator, u.avatar_url as creator_avatar,
|
||||
0 as song_count
|
||||
FROM playlists p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.is_public = true
|
||||
ORDER BY p.created_at DESC
|
||||
LIMIT 20`
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ playlists: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Get featured playlists error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get playlist by ID
|
||||
router.get('/:id', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const playlistResult = await pool.query(
|
||||
`SELECT p.*, u.username as creator, u.avatar_url as creator_avatar
|
||||
FROM playlists p
|
||||
JOIN users u ON p.user_id = u.id
|
||||
WHERE p.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (playlistResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Playlist not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const playlist = playlistResult.rows[0];
|
||||
|
||||
// Access control
|
||||
if (!playlist.is_public && (!req.user || req.user.id !== playlist.user_id)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Get songs
|
||||
const songsResult = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.cover_url, s.audio_url, s.duration,
|
||||
s.user_id, s.is_public, u.username as creator, ps.added_at, ps.position
|
||||
FROM playlist_songs ps
|
||||
JOIN songs s ON ps.song_id = s.id
|
||||
JOIN users u ON s.user_id = u.id
|
||||
WHERE ps.playlist_id = $1
|
||||
ORDER BY ps.position ASC`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
const songs = await Promise.all(
|
||||
songsResult.rows.map(async (row) => ({
|
||||
...row,
|
||||
audio_url: await resolveAccessibleAudioUrl(row.audio_url, row.is_public),
|
||||
}))
|
||||
);
|
||||
|
||||
res.json({
|
||||
playlist,
|
||||
songs
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get playlist details error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add song to playlist
|
||||
router.post('/:id/songs', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { songId } = req.body;
|
||||
|
||||
// Verify playlist ownership
|
||||
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
|
||||
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
|
||||
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
// Get max position
|
||||
const positionResult = await pool.query(
|
||||
'SELECT MAX(position) as max_pos FROM playlist_songs WHERE playlist_id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
const position = (positionResult.rows[0].max_pos || 0) + 1;
|
||||
|
||||
await pool.query(
|
||||
`INSERT INTO playlist_songs (playlist_id, song_id, position)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (playlist_id, song_id) DO NOTHING`,
|
||||
[req.params.id, songId, position]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Add song to playlist error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Remove song from playlist
|
||||
router.delete('/:id/songs/:songId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Verify playlist ownership
|
||||
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
|
||||
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
|
||||
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
await pool.query(
|
||||
'DELETE FROM playlist_songs WHERE playlist_id = $1 AND song_id = $2',
|
||||
[req.params.id, req.params.songId]
|
||||
);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Remove song from playlist error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update playlist
|
||||
router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Verify playlist ownership
|
||||
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
|
||||
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
|
||||
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
const { name, description, isPublic, coverUrl } = req.body;
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (name !== undefined) { updates.push(`name = $${paramCount}`); values.push(name); paramCount++; }
|
||||
if (description !== undefined) { updates.push(`description = $${paramCount}`); values.push(description); paramCount++; }
|
||||
if (isPublic !== undefined) { updates.push(`is_public = $${paramCount}`); values.push(isPublic); paramCount++; }
|
||||
if (coverUrl !== undefined) { updates.push(`cover_url = $${paramCount}`); values.push(coverUrl); paramCount++; }
|
||||
|
||||
if (updates.length > 0) {
|
||||
updates.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
values.push(req.params.id);
|
||||
await pool.query(
|
||||
`UPDATE playlists SET ${updates.join(', ')} WHERE id = $${paramCount}`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await pool.query('SELECT * FROM playlists WHERE id = $1', [req.params.id]);
|
||||
res.json({ playlist: updated.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update playlist error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete playlist
|
||||
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Verify playlist ownership
|
||||
const playlistCheck = await pool.query('SELECT user_id FROM playlists WHERE id = $1', [req.params.id]);
|
||||
if (playlistCheck.rows.length === 0) return res.status(404).json({ error: 'Playlist not found' });
|
||||
if (playlistCheck.rows[0].user_id !== req.user!.id) return res.status(403).json({ error: 'Access denied' });
|
||||
|
||||
await pool.query('DELETE FROM playlists WHERE id = $1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete playlist error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { Router, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { getStorageProvider } from '../services/storage/factory.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/flac', 'audio/mp3', 'audio/x-wav', 'audio/x-flac'];
|
||||
if (allowedTypes.includes(file.mimetype) || file.originalname.match(/\.(mp3|wav|flac)$/i)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only MP3, WAV, and FLAC are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's reference tracks
|
||||
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT id, filename, storage_key, duration, file_size_bytes, tags, created_at
|
||||
FROM reference_tracks
|
||||
WHERE user_id = $1
|
||||
ORDER BY created_at DESC`,
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
const storage = getStorageProvider();
|
||||
const tracks = result.rows.map(row => ({
|
||||
...row,
|
||||
audio_url: storage.getPublicUrl(row.storage_key)
|
||||
}));
|
||||
|
||||
res.json({ tracks });
|
||||
} catch (error) {
|
||||
console.error('Get reference tracks error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload a new reference track
|
||||
router.post('/', authMiddleware, upload.single('audio'), async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'No file uploaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user!.id;
|
||||
const originalFilename = req.file.originalname;
|
||||
const ext = path.extname(originalFilename) || '.mp3';
|
||||
const timestamp = Date.now();
|
||||
const key = `reference-tracks/${userId}/${timestamp}${ext}`;
|
||||
|
||||
const storage = getStorageProvider();
|
||||
await storage.upload(key, req.file.buffer, req.file.mimetype);
|
||||
const audioUrl = storage.getPublicUrl(key);
|
||||
|
||||
// Parse tags from request body if provided
|
||||
const tags = req.body.tags ? JSON.parse(req.body.tags) : null;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO reference_tracks (user_id, filename, storage_key, file_size_bytes, tags)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[userId, originalFilename, key, req.file.size, tags]
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
track: {
|
||||
...result.rows[0],
|
||||
audio_url: audioUrl
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload reference track error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: 'Failed to upload reference track', details: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
// Update reference track (duration, tags)
|
||||
router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Verify ownership
|
||||
const check = await pool.query(
|
||||
'SELECT user_id FROM reference_tracks WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (check.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Track not found' });
|
||||
return;
|
||||
}
|
||||
if (check.rows[0].user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { duration, tags } = req.body;
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (duration !== undefined) {
|
||||
updates.push(`duration = $${paramCount}`);
|
||||
values.push(duration);
|
||||
paramCount++;
|
||||
}
|
||||
if (tags !== undefined) {
|
||||
updates.push(`tags = $${paramCount}`);
|
||||
values.push(tags);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
res.status(400).json({ error: 'No fields to update' });
|
||||
return;
|
||||
}
|
||||
|
||||
values.push(req.params.id);
|
||||
const result = await pool.query(
|
||||
`UPDATE reference_tracks SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
const storage = getStorageProvider();
|
||||
res.json({
|
||||
track: {
|
||||
...result.rows[0],
|
||||
audio_url: storage.getPublicUrl(result.rows[0].storage_key)
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Update reference track error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a reference track
|
||||
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Verify ownership
|
||||
const check = await pool.query(
|
||||
'SELECT user_id, storage_key FROM reference_tracks WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
if (check.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Track not found' });
|
||||
return;
|
||||
}
|
||||
if (check.rows[0].user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete from storage
|
||||
const storage = getStorageProvider();
|
||||
try {
|
||||
await storage.delete(check.rows[0].storage_key);
|
||||
} catch (storageError) {
|
||||
console.error('Failed to delete from storage:', storageError);
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
await pool.query('DELETE FROM reference_tracks WHERE id = $1', [req.params.id]);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete reference track error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,658 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { Readable } from 'node:stream';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authMiddleware, optionalAuthMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { getStorageProvider } from '../services/storage/factory.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Helper: resolve audio URL (generates signed URL for S3)
|
||||
async function resolveAudioUrl(audioUrl: string | null): Promise<string | null> {
|
||||
if (!audioUrl) return null;
|
||||
|
||||
if (audioUrl.startsWith('s3://')) {
|
||||
const storageKey = audioUrl.replace('s3://', '');
|
||||
const storage = getStorageProvider();
|
||||
return storage.getUrl(storageKey, 3600); // 1 hour expiry
|
||||
}
|
||||
|
||||
return audioUrl;
|
||||
}
|
||||
|
||||
// Helper: resolve audio URL for direct playback
|
||||
async function resolveAccessibleAudioUrl(audioUrl: string | null, isPublic: boolean): Promise<string | null> {
|
||||
if (!audioUrl) return null;
|
||||
if (audioUrl.startsWith('s3://')) {
|
||||
const storageKey = audioUrl.replace('s3://', '');
|
||||
const storage = getStorageProvider();
|
||||
return isPublic ? storage.getPublicUrl(storageKey) : storage.getUrl(storageKey, 3600);
|
||||
}
|
||||
return audioUrl;
|
||||
}
|
||||
|
||||
// Get audio - proxies from S3 to avoid CORS issues
|
||||
router.get('/:id/audio', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.audio_url, s.is_public, s.user_id FROM songs s WHERE s.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const song = result.rows[0];
|
||||
|
||||
if (!song.is_public && (!req.user || req.user.id !== song.user_id)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const audioUrl = await resolveAudioUrl(song.audio_url);
|
||||
if (!audioUrl) {
|
||||
res.status(404).json({ error: 'Audio not available' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Local files - redirect
|
||||
if (audioUrl.startsWith('/')) {
|
||||
res.redirect(audioUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// S3/remote - proxy to avoid CORS
|
||||
const range = req.headers.range;
|
||||
const audioRes = await fetch(audioUrl, {
|
||||
headers: range ? { Range: range } : undefined,
|
||||
});
|
||||
if (!audioRes.ok && audioRes.status !== 206) {
|
||||
res.status(502).json({ error: 'Failed to fetch audio' });
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = audioRes.headers.get('content-type') || 'audio/mpeg';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
res.setHeader('Accept-Ranges', 'bytes');
|
||||
|
||||
const contentLength = audioRes.headers.get('content-length');
|
||||
if (contentLength) {
|
||||
res.setHeader('Content-Length', contentLength);
|
||||
}
|
||||
|
||||
const contentRange = audioRes.headers.get('content-range');
|
||||
if (contentRange) {
|
||||
res.status(206);
|
||||
res.setHeader('Content-Range', contentRange);
|
||||
}
|
||||
|
||||
if (audioRes.body) {
|
||||
Readable.fromWeb(audioRes.body as any).pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await audioRes.arrayBuffer();
|
||||
res.send(Buffer.from(arrayBuffer));
|
||||
} catch (error) {
|
||||
console.error('Get audio error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's songs
|
||||
router.get('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public,
|
||||
s.like_count, s.view_count, s.user_id, s.created_at,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.user_id = $1
|
||||
ORDER BY s.created_at DESC`,
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
const songs = await Promise.all(
|
||||
result.rows.map(async (row) => ({
|
||||
...row,
|
||||
audio_url: await resolveAccessibleAudioUrl(row.audio_url, row.is_public),
|
||||
}))
|
||||
);
|
||||
|
||||
res.json({ songs });
|
||||
} catch (error) {
|
||||
console.error('Get songs error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get featured songs (random songs for discover page)
|
||||
router.get('/public/featured', optionalAuthMiddleware, async (_req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Return random songs - for local app, show all songs randomly
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.view_count, s.created_at, s.user_id,
|
||||
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
ORDER BY RANDOM()
|
||||
LIMIT 20`
|
||||
);
|
||||
|
||||
const songs = await Promise.all(
|
||||
result.rows.map(async (row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
lyrics: row.lyrics,
|
||||
style: row.style,
|
||||
caption: row.caption,
|
||||
cover_url: row.cover_url,
|
||||
audio_url: await resolveAccessibleAudioUrl(row.audio_url, true),
|
||||
duration: row.duration,
|
||||
bpm: row.bpm,
|
||||
key_scale: row.key_scale,
|
||||
time_signature: row.time_signature,
|
||||
tags: row.tags || [],
|
||||
like_count: row.like_count || 0,
|
||||
view_count: row.view_count || 0,
|
||||
created_at: row.created_at,
|
||||
creator: row.creator,
|
||||
creator_avatar: row.creator_avatar,
|
||||
user_id: row.user_id,
|
||||
is_public: true
|
||||
}))
|
||||
);
|
||||
|
||||
res.json({ songs });
|
||||
} catch (error) {
|
||||
console.error('Get featured/random songs error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get public songs (for explore/home)
|
||||
router.get('/public', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
|
||||
const offset = parseInt(req.query.offset as string) || 0;
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count, s.created_at,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.is_public = true
|
||||
ORDER BY s.created_at DESC
|
||||
LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
const songs = await Promise.all(
|
||||
result.rows.map(async (row) => ({
|
||||
...row,
|
||||
audio_url: await resolveAccessibleAudioUrl(row.audio_url, true),
|
||||
}))
|
||||
);
|
||||
|
||||
res.json({ songs });
|
||||
} catch (error) {
|
||||
console.error('Get public songs error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single song
|
||||
router.get('/:id', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public, s.like_count, s.view_count, s.created_at,
|
||||
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = $1`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const song = result.rows[0];
|
||||
|
||||
// Check access
|
||||
if (!song.is_public && (!req.user || req.user.id !== song.user_id)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedSong = {
|
||||
...song,
|
||||
audio_url: await resolveAccessibleAudioUrl(song.audio_url, song.is_public),
|
||||
};
|
||||
|
||||
res.json({ song: resolvedSong });
|
||||
} catch (error) {
|
||||
console.error('Get song error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get full song details (including comments)
|
||||
router.get('/:id/full', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const [songResult, commentsResult] = await Promise.all([
|
||||
pool.query(
|
||||
`SELECT s.id, s.user_id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.is_public,
|
||||
s.like_count, s.view_count, s.created_at,
|
||||
COALESCE(u.username, 'Anonymous') as creator, u.avatar_url as creator_avatar
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.id = $1`,
|
||||
[req.params.id]
|
||||
),
|
||||
pool.query(
|
||||
`SELECT c.id, c.content, c.created_at, c.updated_at,
|
||||
u.id as user_id, u.username, u.avatar_url
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.song_id = $1
|
||||
ORDER BY c.created_at DESC`,
|
||||
[req.params.id]
|
||||
)
|
||||
]);
|
||||
|
||||
if (songResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const song = songResult.rows[0];
|
||||
|
||||
// Check access
|
||||
if (!song.is_public && (!req.user || req.user.id !== song.user_id)) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
await pool.query('UPDATE songs SET view_count = view_count + 1 WHERE id = $1', [req.params.id]);
|
||||
|
||||
const resolvedSong = {
|
||||
...song,
|
||||
audio_url: await resolveAccessibleAudioUrl(song.audio_url, song.is_public),
|
||||
};
|
||||
|
||||
res.json({
|
||||
song: resolvedSong,
|
||||
comments: commentsResult.rows
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get full song error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create song (manual, not from generation)
|
||||
router.post('/', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
lyrics,
|
||||
style,
|
||||
caption,
|
||||
coverUrl,
|
||||
audioUrl,
|
||||
duration,
|
||||
bpm,
|
||||
keyScale,
|
||||
timeSignature,
|
||||
tags,
|
||||
isPublic,
|
||||
} = req.body;
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO songs (user_id, title, lyrics, style, caption, cover_url, audio_url,
|
||||
duration, bpm, key_scale, time_signature, tags, is_public)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
req.user!.id,
|
||||
title,
|
||||
lyrics,
|
||||
style,
|
||||
caption,
|
||||
coverUrl,
|
||||
audioUrl,
|
||||
duration,
|
||||
bpm,
|
||||
keyScale,
|
||||
timeSignature,
|
||||
tags || [],
|
||||
isPublic || false,
|
||||
]
|
||||
);
|
||||
|
||||
res.status(201).json({ song: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Create song error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update song
|
||||
router.patch('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Verify ownership
|
||||
const check = await pool.query('SELECT user_id FROM songs WHERE id = $1', [req.params.id]);
|
||||
if (check.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
if (check.rows[0].user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
const allowedFields = ['title', 'lyrics', 'style', 'caption', 'cover_url', 'is_public', 'tags'];
|
||||
for (const field of allowedFields) {
|
||||
if (req.body[field] !== undefined) {
|
||||
updates.push(`${field} = $${paramCount}`);
|
||||
values.push(req.body[field]);
|
||||
paramCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
res.status(400).json({ error: 'No fields to update' });
|
||||
return;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
values.push(req.params.id);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE songs SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
res.json({ song: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update song error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete song
|
||||
router.delete('/:id', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const check = await pool.query('SELECT user_id FROM songs WHERE id = $1', [req.params.id]);
|
||||
if (check.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
if (check.rows[0].user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const audioFileResult = await pool.query(
|
||||
'SELECT id, storage_key, storage_provider FROM audio_files WHERE song_id = $1 AND deleted_at IS NULL',
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (audioFileResult.rows.length > 0) {
|
||||
const storage = getStorageProvider();
|
||||
for (const audioFile of audioFileResult.rows) {
|
||||
try {
|
||||
await storage.delete(audioFile.storage_key);
|
||||
} catch (err) {
|
||||
console.error(`Failed to delete storage file ${audioFile.storage_key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
await pool.query(
|
||||
'UPDATE audio_files SET deleted_at = CURRENT_TIMESTAMP WHERE song_id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM songs WHERE id = $1', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete song error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Like/unlike song
|
||||
router.post('/:id/like', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
// Check if already liked
|
||||
const existing = await client.query(
|
||||
'SELECT 1 FROM liked_songs WHERE user_id = $1 AND song_id = $2',
|
||||
[req.user!.id, req.params.id]
|
||||
);
|
||||
|
||||
if (existing.rows.length > 0) {
|
||||
// Unlike
|
||||
await client.query('DELETE FROM liked_songs WHERE user_id = $1 AND song_id = $2', [
|
||||
req.user!.id,
|
||||
req.params.id,
|
||||
]);
|
||||
// Decrement like_count
|
||||
await client.query(
|
||||
'UPDATE songs SET like_count = GREATEST(like_count - 1, 0) WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.json({ liked: false });
|
||||
} else {
|
||||
// Like
|
||||
await client.query('INSERT INTO liked_songs (user_id, song_id) VALUES ($1, $2)', [
|
||||
req.user!.id,
|
||||
req.params.id,
|
||||
]);
|
||||
// Increment like_count
|
||||
await client.query(
|
||||
'UPDATE songs SET like_count = like_count + 1 WHERE id = $1',
|
||||
[req.params.id]
|
||||
);
|
||||
await client.query('COMMIT');
|
||||
res.json({ liked: true });
|
||||
}
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Like song error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
});
|
||||
|
||||
// Get liked songs
|
||||
router.get('/liked/list', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.cover_url, s.audio_url,
|
||||
s.duration, s.tags, s.like_count, s.created_at, s.is_public,
|
||||
COALESCE(u.username, 'Anonymous') as creator
|
||||
FROM liked_songs ls
|
||||
JOIN songs s ON ls.song_id = s.id
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE ls.user_id = $1
|
||||
ORDER BY ls.liked_at DESC`,
|
||||
[req.user!.id]
|
||||
);
|
||||
|
||||
const songs = await Promise.all(
|
||||
result.rows.map(async (row) => ({
|
||||
...row,
|
||||
audio_url: await resolveAccessibleAudioUrl(row.audio_url, row.is_public),
|
||||
}))
|
||||
);
|
||||
|
||||
res.json({ songs });
|
||||
} catch (error) {
|
||||
console.error('Get liked songs error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle song privacy (paid users only can make songs private)
|
||||
router.patch('/:id/privacy', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
// Get user's account tier
|
||||
const userResult = await pool.query('SELECT account_tier FROM users WHERE id = $1', [req.user!.id]);
|
||||
const accountTier = userResult.rows[0]?.account_tier || 'free';
|
||||
|
||||
const check = await pool.query('SELECT user_id, is_public FROM songs WHERE id = $1', [req.params.id]);
|
||||
if (check.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
if (check.rows[0].user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
const newPublicState = !check.rows[0].is_public;
|
||||
|
||||
// Free users cannot make songs private
|
||||
if (accountTier === 'free' && !newPublicState) {
|
||||
res.status(403).json({ error: 'Upgrade to Pro or Unlimited to make songs private' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('UPDATE songs SET is_public = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2', [
|
||||
newPublicState,
|
||||
req.params.id,
|
||||
]);
|
||||
|
||||
res.json({ isPublic: newPublicState });
|
||||
} catch (error) {
|
||||
console.error('Toggle privacy error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Track song play
|
||||
router.post('/:id/play', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`UPDATE songs
|
||||
SET view_count = COALESCE(view_count, 0) + 1, updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
RETURNING view_count`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ viewCount: result.rows[0].view_count });
|
||||
} catch (error) {
|
||||
console.error('Track play error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get comments for a song
|
||||
router.get('/:id/comments', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT c.id, c.content, c.created_at, u.username, u.id as user_id, u.avatar_url
|
||||
FROM comments c
|
||||
JOIN users u ON c.user_id = u.id
|
||||
WHERE c.song_id = $1
|
||||
ORDER BY c.created_at DESC`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
res.json({ comments: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Get comments error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add comment to a song
|
||||
router.post('/:id/comments', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
|
||||
if (!content || content.trim().length === 0) {
|
||||
res.status(400).json({ error: 'Comment content is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if song exists and is public
|
||||
const songCheck = await pool.query('SELECT is_public FROM songs WHERE id = $1', [req.params.id]);
|
||||
if (songCheck.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Song not found' });
|
||||
return;
|
||||
}
|
||||
if (!songCheck.rows[0].is_public) {
|
||||
res.status(403).json({ error: 'Cannot comment on private songs' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await pool.query(
|
||||
`INSERT INTO comments (song_id, user_id, content)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, content, created_at`,
|
||||
[req.params.id, req.user!.id, content.trim()]
|
||||
);
|
||||
|
||||
const comment = {
|
||||
...result.rows[0],
|
||||
username: req.user!.username,
|
||||
user_id: req.user!.id,
|
||||
};
|
||||
|
||||
res.status(201).json({ comment });
|
||||
} catch (error) {
|
||||
console.error('Add comment error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete comment
|
||||
router.delete('/comments/:commentId', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const check = await pool.query('SELECT user_id FROM comments WHERE id = $1', [req.params.commentId]);
|
||||
if (check.rows.length === 0) {
|
||||
res.status(404).json({ error: 'Comment not found' });
|
||||
return;
|
||||
}
|
||||
if (check.rows[0].user_id !== req.user!.id) {
|
||||
res.status(403).json({ error: 'Access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM comments WHERE id = $1', [req.params.commentId]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete comment error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,445 @@
|
||||
import { Router, Response } from 'express';
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import { pool } from '../db/pool.js';
|
||||
import { authMiddleware, optionalAuthMiddleware, AuthenticatedRequest } from '../middleware/auth.js';
|
||||
import { getStorageProvider } from '../services/storage/factory.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
async function resolvePublicAudioUrl(audioUrl: string | null): Promise<string | null> {
|
||||
if (!audioUrl) return null;
|
||||
if (audioUrl.startsWith('s3://')) {
|
||||
const storageKey = audioUrl.replace('s3://', '');
|
||||
const storage = getStorageProvider();
|
||||
return storage.getPublicUrl(storageKey);
|
||||
}
|
||||
return audioUrl;
|
||||
}
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
|
||||
if (allowedTypes.includes(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Invalid file type. Only JPEG, PNG, WebP, and GIF are allowed.'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Get featured creators (for search/explore page)
|
||||
router.get('/public/featured', async (_req, res: Response) => {
|
||||
try {
|
||||
// First try to get users with public songs
|
||||
let result = await pool.query(
|
||||
`SELECT u.id, u.username, u.bio, u.avatar_url, u.created_at,
|
||||
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) as follower_count,
|
||||
(SELECT COUNT(*) FROM songs WHERE user_id = u.id AND is_public = 1) as song_count
|
||||
FROM users u
|
||||
WHERE EXISTS (SELECT 1 FROM songs WHERE user_id = u.id AND is_public = 1)
|
||||
ORDER BY (SELECT COUNT(*) FROM songs WHERE user_id = u.id AND is_public = 1) DESC,
|
||||
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) DESC
|
||||
LIMIT 20`
|
||||
);
|
||||
|
||||
// Fallback: if no users with public songs, get any recent users
|
||||
if (result.rows.length === 0) {
|
||||
result = await pool.query(
|
||||
`SELECT u.id, u.username, u.bio, u.avatar_url, u.created_at,
|
||||
(SELECT COUNT(*) FROM followers WHERE following_id = u.id) as follower_count,
|
||||
0 as song_count
|
||||
FROM users u
|
||||
ORDER BY u.created_at DESC
|
||||
LIMIT 20`
|
||||
);
|
||||
}
|
||||
|
||||
res.json({ creators: result.rows });
|
||||
} catch (error) {
|
||||
console.error('Get featured creators error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user profile by username
|
||||
router.get('/:username', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await pool.query(
|
||||
`SELECT u.id, u.username, u.created_at, u.bio, u.avatar_url, u.banner_url
|
||||
FROM users u
|
||||
WHERE u.username = $1`,
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = result.rows[0];
|
||||
|
||||
res.json({ user });
|
||||
} catch (error) {
|
||||
console.error('Get user profile error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's public songs
|
||||
router.get('/:username/songs', async (req, res: Response) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
const songsResult = await pool.query(
|
||||
`SELECT s.id, s.title, s.lyrics, s.style, s.caption, s.cover_url, s.audio_url,
|
||||
s.duration, s.bpm, s.key_scale, s.time_signature, s.tags, s.like_count,
|
||||
s.view_count, s.created_at, u.username as creator
|
||||
FROM songs s
|
||||
LEFT JOIN users u ON s.user_id = u.id
|
||||
WHERE s.user_id = $1 AND s.is_public = 1
|
||||
ORDER BY s.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
const songs = await Promise.all(
|
||||
songsResult.rows.map(async (row) => ({
|
||||
...row,
|
||||
audio_url: await resolvePublicAudioUrl(row.audio_url),
|
||||
}))
|
||||
);
|
||||
|
||||
res.json({ songs });
|
||||
} catch (error) {
|
||||
console.error('Get user songs error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's public playlists
|
||||
router.get('/:username/playlists', async (req, res: Response) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
const playlistsResult = await pool.query(
|
||||
`SELECT p.id, p.name, p.description, p.cover_url, p.created_at,
|
||||
COUNT(ps.song_id) as song_count
|
||||
FROM playlists p
|
||||
LEFT JOIN playlist_songs ps ON p.id = ps.playlist_id
|
||||
WHERE p.user_id = $1 AND p.is_public = 1
|
||||
GROUP BY p.id
|
||||
ORDER BY p.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ playlists: playlistsResult.rows });
|
||||
} catch (error) {
|
||||
console.error('Get user playlists error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload avatar image
|
||||
router.post('/me/avatar', authMiddleware, upload.single('avatar'), async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'No file uploaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user!.id;
|
||||
const ext = path.extname(req.file.originalname) || '.jpg';
|
||||
const key = `users/${userId}/avatar${ext}`;
|
||||
|
||||
const storage = getStorageProvider();
|
||||
await storage.upload(key, req.file.buffer, req.file.mimetype);
|
||||
const url = storage.getPublicUrl(key);
|
||||
|
||||
// Update user record
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET avatar_url = $1, updated_at = datetime('now') WHERE id = $2 RETURNING id, username, created_at, bio, avatar_url, banner_url`,
|
||||
[url, userId]
|
||||
);
|
||||
|
||||
res.json({ user: result.rows[0], url });
|
||||
} catch (error) {
|
||||
console.error('Avatar upload error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
res.status(500).json({ error: 'Failed to upload avatar', details: errorMessage });
|
||||
}
|
||||
});
|
||||
|
||||
// Upload banner image
|
||||
router.post('/me/banner', authMiddleware, upload.single('banner'), async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'No file uploaded' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = req.user!.id;
|
||||
const ext = path.extname(req.file.originalname) || '.jpg';
|
||||
const key = `users/${userId}/banner${ext}`;
|
||||
|
||||
const storage = getStorageProvider();
|
||||
await storage.upload(key, req.file.buffer, req.file.mimetype);
|
||||
const url = storage.getPublicUrl(key);
|
||||
|
||||
// Update user record
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET banner_url = $1, updated_at = datetime('now') WHERE id = $2 RETURNING id, username, created_at, bio, avatar_url, banner_url`,
|
||||
[url, userId]
|
||||
);
|
||||
|
||||
res.json({ user: result.rows[0], url });
|
||||
} catch (error) {
|
||||
console.error('Banner upload error:', error);
|
||||
res.status(500).json({ error: 'Failed to upload banner' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update own profile
|
||||
router.patch('/me', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const { username, bio, avatarUrl, bannerUrl } = req.body;
|
||||
|
||||
const updates: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let paramCount = 1;
|
||||
|
||||
if (username !== undefined) {
|
||||
// Check if username is already taken
|
||||
const existing = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1 AND id != $2',
|
||||
[username, req.user!.id]
|
||||
);
|
||||
if (existing.rows.length > 0) {
|
||||
res.status(400).json({ error: 'Username already taken' });
|
||||
return;
|
||||
}
|
||||
updates.push(`username = $${paramCount}`);
|
||||
values.push(username);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (bio !== undefined) {
|
||||
updates.push(`bio = $${paramCount}`);
|
||||
values.push(bio);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (avatarUrl !== undefined) {
|
||||
updates.push(`avatar_url = $${paramCount}`);
|
||||
values.push(avatarUrl);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (bannerUrl !== undefined) {
|
||||
updates.push(`banner_url = $${paramCount}`);
|
||||
values.push(bannerUrl);
|
||||
paramCount++;
|
||||
}
|
||||
|
||||
if (updates.length === 0) {
|
||||
res.status(400).json({ error: 'No fields to update' });
|
||||
return;
|
||||
}
|
||||
|
||||
updates.push(`updated_at = datetime('now')`);
|
||||
values.push(req.user!.id);
|
||||
|
||||
const result = await pool.query(
|
||||
`UPDATE users SET ${updates.join(', ')} WHERE id = $${paramCount} RETURNING id, username, created_at, bio, avatar_url, banner_url`,
|
||||
values
|
||||
);
|
||||
|
||||
res.json({ user: result.rows[0] });
|
||||
} catch (error) {
|
||||
console.error('Update profile error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle follow/unfollow user
|
||||
router.post('/:username/follow', authMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const targetUserId = userResult.rows[0].id;
|
||||
const currentUserId = req.user!.id;
|
||||
|
||||
if (targetUserId === currentUserId) {
|
||||
res.status(400).json({ error: 'Cannot follow yourself' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if already following
|
||||
const existingFollow = await pool.query(
|
||||
'SELECT 1 FROM followers WHERE follower_id = $1 AND following_id = $2',
|
||||
[currentUserId, targetUserId]
|
||||
);
|
||||
|
||||
let following = false;
|
||||
|
||||
if (existingFollow.rows.length > 0) {
|
||||
// Unfollow
|
||||
await pool.query(
|
||||
'DELETE FROM followers WHERE follower_id = $1 AND following_id = $2',
|
||||
[currentUserId, targetUserId]
|
||||
);
|
||||
following = false;
|
||||
} else {
|
||||
// Follow
|
||||
await pool.query(
|
||||
'INSERT INTO followers (follower_id, following_id) VALUES ($1, $2)',
|
||||
[currentUserId, targetUserId]
|
||||
);
|
||||
following = true;
|
||||
}
|
||||
|
||||
// Get updated follower count
|
||||
const countResult = await pool.query(
|
||||
'SELECT COUNT(*) as count FROM followers WHERE following_id = $1',
|
||||
[targetUserId]
|
||||
);
|
||||
|
||||
res.json({
|
||||
following,
|
||||
followerCount: parseInt(countResult.rows[0].count)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Toggle follow error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user's followers
|
||||
router.get('/:username/followers', async (req, res: Response) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
const followersResult = await pool.query(
|
||||
`SELECT u.id, u.username, u.created_at
|
||||
FROM followers f
|
||||
JOIN users u ON f.follower_id = u.id
|
||||
WHERE f.following_id = $1
|
||||
ORDER BY f.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ followers: followersResult.rows });
|
||||
} catch (error) {
|
||||
console.error('Get followers error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get users that this user follows
|
||||
router.get('/:username/following', async (req, res: Response) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
const followingResult = await pool.query(
|
||||
`SELECT u.id, u.username, u.created_at
|
||||
FROM followers f
|
||||
JOIN users u ON f.following_id = u.id
|
||||
WHERE f.follower_id = $1
|
||||
ORDER BY f.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
res.json({ following: followingResult.rows });
|
||||
} catch (error) {
|
||||
console.error('Get following error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get user stats (follower/following counts)
|
||||
router.get('/:username/stats', optionalAuthMiddleware, async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userResult = await pool.query(
|
||||
'SELECT id FROM users WHERE username = $1',
|
||||
[req.params.username]
|
||||
);
|
||||
|
||||
if (userResult.rows.length === 0) {
|
||||
res.status(404).json({ error: 'User not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = userResult.rows[0].id;
|
||||
|
||||
const [followerCountResult, followingCountResult, isFollowingResult] = await Promise.all([
|
||||
pool.query('SELECT COUNT(*) as count FROM followers WHERE following_id = $1', [userId]),
|
||||
pool.query('SELECT COUNT(*) as count FROM followers WHERE follower_id = $1', [userId]),
|
||||
req.user
|
||||
? pool.query(
|
||||
'SELECT 1 FROM followers WHERE follower_id = $1 AND following_id = $2',
|
||||
[req.user.id, userId]
|
||||
)
|
||||
: Promise.resolve({ rows: [] })
|
||||
]);
|
||||
|
||||
res.json({
|
||||
followerCount: parseInt(followerCountResult.rows[0].count),
|
||||
followingCount: parseInt(followingCountResult.rows[0].count),
|
||||
isFollowing: isFollowingResult.rows.length > 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get user stats error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,34 @@
|
||||
import { pool } from '../db/pool.js';
|
||||
|
||||
async function run() {
|
||||
const users = await pool.query(
|
||||
`SELECT id, email FROM users
|
||||
WHERE avatar_url IS NULL AND email IS NOT NULL`
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
for (const row of users.rows) {
|
||||
const email = String(row.email || '').toLowerCase();
|
||||
if (!email) continue;
|
||||
const hash = await hashEmail(email);
|
||||
const gravatar = `https://www.gravatar.com/avatar/${hash}?d=identicon&s=256`;
|
||||
await pool.query(
|
||||
`UPDATE users SET avatar_url = $1 WHERE id = $2`,
|
||||
[gravatar, row.id]
|
||||
);
|
||||
updated += 1;
|
||||
}
|
||||
|
||||
console.log(`[backfill] Updated ${updated} users with gravatar identicons.`);
|
||||
await pool.end();
|
||||
}
|
||||
|
||||
async function hashEmail(email: string): Promise<string> {
|
||||
const crypto = await import('crypto');
|
||||
return crypto.createHash('md5').update(email.trim().toLowerCase()).digest('hex');
|
||||
}
|
||||
|
||||
run().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { generationQueue } from '../services/generationQueue.js';
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const results: string[] = [];
|
||||
|
||||
generationQueue.setConfig({
|
||||
maxTotalWorkers: 2,
|
||||
maxFreeWorkers: 1,
|
||||
maxPerUser: 1,
|
||||
batchWindowMs: 200,
|
||||
batchSize: 4,
|
||||
});
|
||||
|
||||
const jobs = [
|
||||
{ id: 'job-free-1', userId: 'u1', tier: 'free', delay: 300 },
|
||||
{ id: 'job-pro-1', userId: 'u2', tier: 'pro', delay: 200 },
|
||||
{ id: 'job-pro-2', userId: 'u2', tier: 'pro', delay: 200 },
|
||||
{ id: 'job-unlimited-1', userId: 'u3', tier: 'unlimited', delay: 150 },
|
||||
];
|
||||
|
||||
const done = new Promise<void>((resolve) => {
|
||||
let remaining = jobs.length;
|
||||
for (const job of jobs) {
|
||||
generationQueue.enqueue({
|
||||
id: job.id,
|
||||
userId: job.userId,
|
||||
tier: job.tier as 'free' | 'pro' | 'unlimited',
|
||||
createdAt: Date.now(),
|
||||
params: {
|
||||
lyrics: 'test',
|
||||
style: 'test',
|
||||
title: 'test',
|
||||
duration: 30,
|
||||
},
|
||||
run: async () => {
|
||||
results.push(`start:${job.id}`);
|
||||
await sleep(job.delay);
|
||||
results.push(`end:${job.id}`);
|
||||
generationQueue.markJobFinished(job.id);
|
||||
remaining -= 1;
|
||||
if (remaining === 0) {
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await done;
|
||||
|
||||
console.log('[test-queue] Order:', results.join(' | '));
|
||||
console.log('[test-queue] OK');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('[test-queue] Failed', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,668 @@
|
||||
import { writeFile, mkdir, copyFile, rm, stat } from 'fs/promises';
|
||||
import { spawn, execSync } from 'child_process';
|
||||
import path from 'path';
|
||||
|
||||
// Get audio duration using ffprobe
|
||||
function getAudioDuration(filePath: string): number {
|
||||
try {
|
||||
const result = execSync(
|
||||
`ffprobe -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 "${filePath}"`,
|
||||
{ encoding: 'utf-8', timeout: 10000 }
|
||||
);
|
||||
const duration = parseFloat(result.trim());
|
||||
return isNaN(duration) ? 0 : Math.round(duration);
|
||||
} catch (error) {
|
||||
console.warn('Failed to get audio duration:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
import { fileURLToPath } from 'url';
|
||||
import { config } from '../config/index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const AUDIO_DIR = path.join(__dirname, '../../public/audio');
|
||||
|
||||
const ACESTEP_API = config.acestep.apiUrl;
|
||||
|
||||
// Resolve ACE-Step path (from env or default relative path)
|
||||
function resolveAceStepPath(): string {
|
||||
const envPath = process.env.ACESTEP_PATH;
|
||||
if (envPath) {
|
||||
return path.isAbsolute(envPath) ? envPath : path.resolve(process.cwd(), envPath);
|
||||
}
|
||||
// Default: sibling directory
|
||||
return path.resolve(__dirname, '../../../../ACE-Step-1.5');
|
||||
}
|
||||
|
||||
const ACESTEP_DIR = resolveAceStepPath();
|
||||
const SCRIPTS_DIR = path.join(__dirname, '../../scripts');
|
||||
const PYTHON_SCRIPT = path.join(SCRIPTS_DIR, 'simple_generate.py');
|
||||
|
||||
export interface GenerationParams {
|
||||
// Mode
|
||||
customMode: boolean;
|
||||
|
||||
// Simple Mode
|
||||
songDescription?: string;
|
||||
|
||||
// Custom Mode
|
||||
lyrics: string;
|
||||
style: string;
|
||||
title: string;
|
||||
|
||||
// Common
|
||||
instrumental: boolean;
|
||||
vocalLanguage?: string;
|
||||
|
||||
// Music Parameters
|
||||
duration?: number;
|
||||
bpm?: number;
|
||||
keyScale?: string;
|
||||
timeSignature?: string;
|
||||
|
||||
// Generation Settings
|
||||
inferenceSteps?: number;
|
||||
guidanceScale?: number;
|
||||
batchSize?: number;
|
||||
randomSeed?: boolean;
|
||||
seed?: number;
|
||||
thinking?: boolean;
|
||||
audioFormat?: 'mp3' | 'flac';
|
||||
inferMethod?: 'ode' | 'sde';
|
||||
shift?: number;
|
||||
|
||||
// LM Parameters
|
||||
lmTemperature?: number;
|
||||
lmCfgScale?: number;
|
||||
lmTopK?: number;
|
||||
lmTopP?: number;
|
||||
lmNegativePrompt?: string;
|
||||
|
||||
// Expert Parameters
|
||||
referenceAudioUrl?: string;
|
||||
sourceAudioUrl?: string;
|
||||
audioCodes?: string;
|
||||
repaintingStart?: number;
|
||||
repaintingEnd?: number;
|
||||
instruction?: string;
|
||||
audioCoverStrength?: number;
|
||||
taskType?: string;
|
||||
useAdg?: boolean;
|
||||
cfgIntervalStart?: number;
|
||||
cfgIntervalEnd?: number;
|
||||
customTimesteps?: string;
|
||||
useCotMetas?: boolean;
|
||||
useCotCaption?: boolean;
|
||||
useCotLanguage?: boolean;
|
||||
autogen?: boolean;
|
||||
constrainedDecodingDebug?: boolean;
|
||||
allowLmBatch?: boolean;
|
||||
getScores?: boolean;
|
||||
getLrc?: boolean;
|
||||
scoreScale?: number;
|
||||
lmBatchChunkSize?: number;
|
||||
trackName?: string;
|
||||
completeTrackClasses?: string[];
|
||||
isFormatCaption?: boolean;
|
||||
}
|
||||
|
||||
interface GenerationResult {
|
||||
audioUrls: string[];
|
||||
duration: number;
|
||||
bpm?: number;
|
||||
keyScale?: string;
|
||||
timeSignature?: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface JobStatus {
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
queuePosition?: number;
|
||||
etaSeconds?: number;
|
||||
result?: GenerationResult;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface ActiveJob {
|
||||
params: GenerationParams;
|
||||
startTime: number;
|
||||
status: 'queued' | 'running' | 'succeeded' | 'failed';
|
||||
taskId?: string;
|
||||
result?: GenerationResult;
|
||||
error?: string;
|
||||
processPromise?: Promise<void>;
|
||||
rawResponse?: unknown;
|
||||
queuePosition?: number;
|
||||
}
|
||||
|
||||
const activeJobs = new Map<string, ActiveJob>();
|
||||
|
||||
// Job queue for sequential processing (GPU can only handle one job at a time)
|
||||
const jobQueue: string[] = [];
|
||||
let isProcessingQueue = false;
|
||||
|
||||
// Health check - verify Python script exists
|
||||
export async function checkSpaceHealth(): Promise<boolean> {
|
||||
try {
|
||||
const { access } = await import('fs/promises');
|
||||
await access(PYTHON_SCRIPT);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Discover endpoints (for compatibility)
|
||||
export async function discoverEndpoints(): Promise<unknown> {
|
||||
return { provider: 'acestep-local', endpoint: ACESTEP_API };
|
||||
}
|
||||
|
||||
// Reset client (no-op for REST API)
|
||||
export function resetClient(): void {
|
||||
// No client to reset for REST API
|
||||
}
|
||||
|
||||
// Process the job queue sequentially
|
||||
async function processQueue(): Promise<void> {
|
||||
if (isProcessingQueue) return;
|
||||
isProcessingQueue = true;
|
||||
|
||||
while (jobQueue.length > 0) {
|
||||
const jobId = jobQueue[0];
|
||||
const job = activeJobs.get(jobId);
|
||||
|
||||
if (job && job.status === 'queued') {
|
||||
try {
|
||||
await processGeneration(jobId, job.params, job);
|
||||
} catch (error) {
|
||||
console.error(`Queue processing error for ${jobId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from queue after processing (whether success or failure)
|
||||
jobQueue.shift();
|
||||
|
||||
// Update queue positions for remaining jobs
|
||||
jobQueue.forEach((id, index) => {
|
||||
const queuedJob = activeJobs.get(id);
|
||||
if (queuedJob) {
|
||||
queuedJob.queuePosition = index + 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
isProcessingQueue = false;
|
||||
}
|
||||
|
||||
// Submit generation job to queue
|
||||
export async function generateMusicViaAPI(params: GenerationParams): Promise<{ jobId: string }> {
|
||||
const jobId = `job_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
|
||||
const job: ActiveJob = {
|
||||
params,
|
||||
startTime: Date.now(),
|
||||
status: 'queued',
|
||||
queuePosition: jobQueue.length + 1,
|
||||
};
|
||||
|
||||
activeJobs.set(jobId, job);
|
||||
jobQueue.push(jobId);
|
||||
|
||||
console.log(`Job ${jobId}: Queued at position ${job.queuePosition}`);
|
||||
|
||||
// Start processing the queue (will be a no-op if already processing)
|
||||
processQueue().catch(err => console.error('Queue processing error:', err));
|
||||
|
||||
return { jobId };
|
||||
}
|
||||
|
||||
async function processGeneration(
|
||||
jobId: string,
|
||||
params: GenerationParams,
|
||||
job: ActiveJob
|
||||
): Promise<void> {
|
||||
job.status = 'running';
|
||||
|
||||
// Build prompt for generation
|
||||
const caption = params.style || 'pop music';
|
||||
const prompt = params.customMode ? caption : (params.songDescription || caption);
|
||||
const lyrics = params.instrumental ? '' : (params.lyrics || '');
|
||||
|
||||
console.log(`Job ${jobId}: Starting generation via Python script`, {
|
||||
prompt: prompt.slice(0, 50),
|
||||
lyricsPreview: lyrics.slice(0, 50),
|
||||
duration: params.duration,
|
||||
batchSize: params.batchSize,
|
||||
});
|
||||
|
||||
try {
|
||||
// Create unique output directory for this job to avoid conflicts with concurrent jobs
|
||||
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
|
||||
await mkdir(jobOutputDir, { recursive: true });
|
||||
|
||||
// Build command arguments for the Python script
|
||||
const args = [
|
||||
'--prompt', prompt,
|
||||
'--duration', String(params.duration ?? 60),
|
||||
'--batch-size', String(params.batchSize ?? 1),
|
||||
'--infer-steps', String(params.inferenceSteps ?? 8),
|
||||
'--guidance-scale', String(params.guidanceScale ?? 10.0),
|
||||
'--audio-format', params.audioFormat ?? 'mp3',
|
||||
'--output-dir', jobOutputDir,
|
||||
'--json',
|
||||
];
|
||||
|
||||
// Basic parameters
|
||||
if (lyrics) {
|
||||
args.push('--lyrics', lyrics);
|
||||
}
|
||||
if (params.instrumental) {
|
||||
args.push('--instrumental');
|
||||
}
|
||||
if (params.bpm && params.bpm > 0) {
|
||||
args.push('--bpm', String(params.bpm));
|
||||
}
|
||||
if (params.keyScale) {
|
||||
args.push('--key-scale', params.keyScale);
|
||||
}
|
||||
if (params.timeSignature) {
|
||||
args.push('--time-signature', params.timeSignature);
|
||||
}
|
||||
if (params.vocalLanguage) {
|
||||
args.push('--vocal-language', params.vocalLanguage);
|
||||
}
|
||||
if (params.seed !== undefined && params.seed >= 0 && !params.randomSeed) {
|
||||
args.push('--seed', String(params.seed));
|
||||
}
|
||||
if (params.shift !== undefined) {
|
||||
args.push('--shift', String(params.shift));
|
||||
}
|
||||
|
||||
// Task type parameters
|
||||
if (params.taskType && params.taskType !== 'text2music') {
|
||||
args.push('--task-type', params.taskType);
|
||||
}
|
||||
if (params.referenceAudioUrl) {
|
||||
// Convert URL path to filesystem path
|
||||
let refAudioPath = params.referenceAudioUrl;
|
||||
if (refAudioPath.startsWith('/audio/')) {
|
||||
refAudioPath = path.join(AUDIO_DIR, refAudioPath.replace('/audio/', ''));
|
||||
}
|
||||
args.push('--reference-audio', refAudioPath);
|
||||
}
|
||||
if (params.sourceAudioUrl) {
|
||||
// Convert URL path to filesystem path
|
||||
let srcAudioPath = params.sourceAudioUrl;
|
||||
if (srcAudioPath.startsWith('/audio/')) {
|
||||
srcAudioPath = path.join(AUDIO_DIR, srcAudioPath.replace('/audio/', ''));
|
||||
}
|
||||
args.push('--src-audio', srcAudioPath);
|
||||
}
|
||||
if (params.audioCodes) {
|
||||
args.push('--audio-codes', params.audioCodes);
|
||||
}
|
||||
if (params.repaintingStart !== undefined && params.repaintingStart > 0) {
|
||||
args.push('--repainting-start', String(params.repaintingStart));
|
||||
}
|
||||
if (params.repaintingEnd !== undefined && params.repaintingEnd > 0) {
|
||||
args.push('--repainting-end', String(params.repaintingEnd));
|
||||
}
|
||||
if (params.audioCoverStrength !== undefined && params.audioCoverStrength !== 1.0) {
|
||||
args.push('--audio-cover-strength', String(params.audioCoverStrength));
|
||||
}
|
||||
if (params.instruction) {
|
||||
args.push('--instruction', params.instruction);
|
||||
}
|
||||
|
||||
// LM/CoT parameters
|
||||
if (params.thinking) {
|
||||
args.push('--thinking');
|
||||
}
|
||||
if (params.lmTemperature !== undefined) {
|
||||
args.push('--lm-temperature', String(params.lmTemperature));
|
||||
}
|
||||
if (params.lmCfgScale !== undefined) {
|
||||
args.push('--lm-cfg-scale', String(params.lmCfgScale));
|
||||
}
|
||||
if (params.lmTopK !== undefined && params.lmTopK > 0) {
|
||||
args.push('--lm-top-k', String(params.lmTopK));
|
||||
}
|
||||
if (params.lmTopP !== undefined) {
|
||||
args.push('--lm-top-p', String(params.lmTopP));
|
||||
}
|
||||
if (params.lmNegativePrompt) {
|
||||
args.push('--lm-negative-prompt', params.lmNegativePrompt);
|
||||
}
|
||||
|
||||
// CoT parameters (pass when disabled, since they default to true)
|
||||
if (params.useCotMetas === false) {
|
||||
args.push('--no-cot-metas');
|
||||
}
|
||||
if (params.useCotCaption === false) {
|
||||
args.push('--no-cot-caption');
|
||||
}
|
||||
if (params.useCotLanguage === false) {
|
||||
args.push('--no-cot-language');
|
||||
}
|
||||
|
||||
// Advanced parameters
|
||||
if (params.useAdg) {
|
||||
args.push('--use-adg');
|
||||
}
|
||||
if (params.cfgIntervalStart !== undefined && params.cfgIntervalStart > 0) {
|
||||
args.push('--cfg-interval-start', String(params.cfgIntervalStart));
|
||||
}
|
||||
if (params.cfgIntervalEnd !== undefined && params.cfgIntervalEnd < 1.0) {
|
||||
args.push('--cfg-interval-end', String(params.cfgIntervalEnd));
|
||||
}
|
||||
|
||||
// Run the Python script
|
||||
const result = await runPythonGeneration(args);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Generation failed');
|
||||
}
|
||||
|
||||
if (!result.audio_paths || result.audio_paths.length === 0) {
|
||||
throw new Error('No audio files generated');
|
||||
}
|
||||
|
||||
// Copy audio files to public directory and build URLs
|
||||
const audioUrls: string[] = [];
|
||||
let actualDuration = 0;
|
||||
for (const srcPath of result.audio_paths) {
|
||||
const ext = srcPath.includes('.flac') ? '.flac' : '.mp3';
|
||||
const filename = `${jobId}_${audioUrls.length}${ext}`;
|
||||
const destPath = path.join(AUDIO_DIR, filename);
|
||||
|
||||
await mkdir(AUDIO_DIR, { recursive: true });
|
||||
await copyFile(srcPath, destPath);
|
||||
|
||||
// Get actual audio duration from first file
|
||||
if (audioUrls.length === 0) {
|
||||
actualDuration = getAudioDuration(destPath);
|
||||
}
|
||||
|
||||
audioUrls.push(`/audio/${filename}`);
|
||||
}
|
||||
|
||||
// Clean up job-specific output directory
|
||||
try {
|
||||
await rm(jobOutputDir, { recursive: true, force: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn(`Job ${jobId}: Failed to cleanup output dir`, cleanupError);
|
||||
}
|
||||
|
||||
// Use actual duration, or fall back to params if > 0, otherwise default to 60
|
||||
const finalDuration = actualDuration > 0 ? actualDuration : (params.duration && params.duration > 0 ? params.duration : 60);
|
||||
|
||||
job.status = 'succeeded';
|
||||
job.result = {
|
||||
audioUrls,
|
||||
duration: finalDuration,
|
||||
bpm: params.bpm,
|
||||
keyScale: params.keyScale,
|
||||
timeSignature: params.timeSignature,
|
||||
status: 'succeeded',
|
||||
};
|
||||
job.rawResponse = result;
|
||||
console.log(`Job ${jobId}: Completed in ${result.elapsed_seconds?.toFixed(1)}s with ${audioUrls.length} audio files`);
|
||||
|
||||
} catch (error) {
|
||||
console.error(`Job ${jobId}: Generation failed`, error);
|
||||
job.status = 'failed';
|
||||
job.error = error instanceof Error ? error.message : 'Generation failed';
|
||||
|
||||
// Try to clean up job output directory on failure too
|
||||
try {
|
||||
const jobOutputDir = path.join(ACESTEP_DIR, 'output', jobId);
|
||||
await rm(jobOutputDir, { recursive: true, force: true });
|
||||
} catch { /* ignore cleanup errors */ }
|
||||
}
|
||||
}
|
||||
|
||||
interface PythonResult {
|
||||
success: boolean;
|
||||
audio_paths?: string[];
|
||||
elapsed_seconds?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function runPythonGeneration(scriptArgs: string[]): Promise<PythonResult> {
|
||||
return new Promise((resolve) => {
|
||||
// Use the ACE-Step venv's Python directly
|
||||
const pythonPath = path.join(ACESTEP_DIR, '.venv', 'bin', 'python');
|
||||
const args = [PYTHON_SCRIPT, ...scriptArgs];
|
||||
|
||||
const proc = spawn(pythonPath, args, {
|
||||
cwd: ACESTEP_DIR,
|
||||
env: {
|
||||
...process.env,
|
||||
CUDA_VISIBLE_DEVICES: '0',
|
||||
ACESTEP_PATH: ACESTEP_DIR,
|
||||
},
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
// Log progress to console
|
||||
const lines = data.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
console.log(`[ACE-Step] ${line}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve({ success: false, error: stderr || `Process exited with code ${code}` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the JSON output (last line that starts with {)
|
||||
const lines = stdout.split('\n').filter(l => l.trim());
|
||||
const jsonLine = lines.find(l => l.startsWith('{'));
|
||||
|
||||
if (!jsonLine) {
|
||||
resolve({ success: false, error: 'No JSON output from generation script' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(jsonLine);
|
||||
resolve(result);
|
||||
} catch {
|
||||
resolve({ success: false, error: 'Invalid JSON from generation script' });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
resolve({ success: false, error: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function extractAudioFiles(result: unknown): string[] {
|
||||
const urls: string[] = [];
|
||||
|
||||
function processItem(item: unknown): void {
|
||||
if (!item) return;
|
||||
|
||||
if (typeof item === 'string') {
|
||||
if (item.includes('.mp3') || item.includes('.wav') || item.includes('.flac')) {
|
||||
urls.push(item);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
for (const subItem of item) {
|
||||
processItem(subItem);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof item === 'object') {
|
||||
const obj = item as Record<string, unknown>;
|
||||
|
||||
// Check common audio path fields
|
||||
if (obj.audio_path && typeof obj.audio_path === 'string') {
|
||||
urls.push(obj.audio_path);
|
||||
}
|
||||
if (obj.path && typeof obj.path === 'string') {
|
||||
urls.push(obj.path);
|
||||
}
|
||||
if (obj.url && typeof obj.url === 'string') {
|
||||
urls.push(obj.url);
|
||||
}
|
||||
if (obj.file && typeof obj.file === 'string') {
|
||||
urls.push(obj.file);
|
||||
}
|
||||
|
||||
// Recursively check arrays and objects
|
||||
for (const key of Object.keys(obj)) {
|
||||
const val = obj[key];
|
||||
if (Array.isArray(val) || (typeof val === 'object' && val !== null)) {
|
||||
processItem(val);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
processItem(result);
|
||||
return [...new Set(urls)];
|
||||
}
|
||||
|
||||
// Get job status
|
||||
export async function getJobStatus(jobId: string): Promise<JobStatus> {
|
||||
const job = activeJobs.get(jobId);
|
||||
|
||||
if (!job) {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: 'Job not found',
|
||||
};
|
||||
}
|
||||
|
||||
if (job.status === 'succeeded' && job.result) {
|
||||
return {
|
||||
status: 'succeeded',
|
||||
result: job.result,
|
||||
};
|
||||
}
|
||||
|
||||
if (job.status === 'failed') {
|
||||
return {
|
||||
status: 'failed',
|
||||
error: job.error || 'Generation failed',
|
||||
};
|
||||
}
|
||||
|
||||
const elapsed = Math.floor((Date.now() - job.startTime) / 1000);
|
||||
|
||||
// Include queue position if queued
|
||||
if (job.status === 'queued') {
|
||||
return {
|
||||
status: job.status,
|
||||
queuePosition: job.queuePosition,
|
||||
etaSeconds: (job.queuePosition || 1) * 180, // ~3 min per job estimate
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: job.status,
|
||||
etaSeconds: Math.max(0, 180 - elapsed), // 3 min estimate
|
||||
};
|
||||
}
|
||||
|
||||
// Get raw response for debugging
|
||||
export function getJobRawResponse(jobId: string): unknown | null {
|
||||
const job = activeJobs.get(jobId);
|
||||
return job?.rawResponse || null;
|
||||
}
|
||||
|
||||
// Get audio stream from local file or remote URL
|
||||
export async function getAudioStream(audioPath: string): Promise<Response> {
|
||||
// If it's already a full URL, fetch directly
|
||||
if (audioPath.startsWith('http')) {
|
||||
return fetch(audioPath);
|
||||
}
|
||||
|
||||
// If it's a local /audio/ path, read from filesystem
|
||||
if (audioPath.startsWith('/audio/')) {
|
||||
const localPath = path.join(AUDIO_DIR, audioPath.replace('/audio/', ''));
|
||||
try {
|
||||
const { readFile } = await import('fs/promises');
|
||||
const buffer = await readFile(localPath);
|
||||
const ext = localPath.endsWith('.flac') ? 'flac' : 'mpeg';
|
||||
return new Response(buffer, {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': `audio/${ext}` }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to read local audio file:', localPath, err);
|
||||
return new Response(null, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, use the ACE-Step audio endpoint
|
||||
const url = `${ACESTEP_API}/v1/audio?path=${encodeURIComponent(audioPath)}`;
|
||||
console.log('Fetching audio from:', url);
|
||||
return fetch(url);
|
||||
}
|
||||
|
||||
// Download audio to local storage
|
||||
export async function downloadAudio(remoteUrl: string, songId: string): Promise<string> {
|
||||
await mkdir(AUDIO_DIR, { recursive: true });
|
||||
|
||||
const response = await getAudioStream(remoteUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download audio: ${response.status}`);
|
||||
}
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
const ext = remoteUrl.includes('.flac') ? '.flac' : '.mp3';
|
||||
const filename = `${songId}${ext}`;
|
||||
const filepath = path.join(AUDIO_DIR, filename);
|
||||
|
||||
await writeFile(filepath, Buffer.from(buffer));
|
||||
console.log(`Downloaded audio to ${filepath}`);
|
||||
|
||||
return `/audio/${filename}`;
|
||||
}
|
||||
|
||||
// Download audio to buffer
|
||||
export async function downloadAudioToBuffer(remoteUrl: string): Promise<{ buffer: Buffer; size: number }> {
|
||||
const response = await getAudioStream(remoteUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download audio: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
return { buffer, size: buffer.length };
|
||||
}
|
||||
|
||||
// Cleanup job from memory
|
||||
export function cleanupJob(jobId: string): void {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
|
||||
// Cleanup old jobs
|
||||
export function cleanupOldJobs(maxAgeMs: number = 3600000): void {
|
||||
const now = Date.now();
|
||||
for (const [jobId, job] of activeJobs) {
|
||||
if (now - job.startTime > maxAgeMs) {
|
||||
activeJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { pool } from '../db/pool.js';
|
||||
import { getStorageProvider } from './storage/factory.js';
|
||||
|
||||
export interface CleanupResult {
|
||||
deleted: number;
|
||||
errors: number;
|
||||
}
|
||||
|
||||
export async function runCleanupJob(): Promise<CleanupResult> {
|
||||
console.log('Starting audio file cleanup job...');
|
||||
|
||||
const result = await pool.query(
|
||||
`SELECT af.id, af.song_id, af.storage_key, af.storage_provider
|
||||
FROM audio_files af
|
||||
WHERE af.expires_at < NOW()
|
||||
AND af.deleted_at IS NULL`
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
console.log('No expired audio files to clean up');
|
||||
return { deleted: 0, errors: 0 };
|
||||
}
|
||||
|
||||
console.log(`Found ${result.rows.length} expired audio files`);
|
||||
|
||||
const storage = getStorageProvider();
|
||||
let deleted = 0;
|
||||
let errors = 0;
|
||||
|
||||
for (const audioFile of result.rows) {
|
||||
try {
|
||||
await storage.delete(audioFile.storage_key);
|
||||
|
||||
await pool.query(
|
||||
'UPDATE audio_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = $1',
|
||||
[audioFile.id]
|
||||
);
|
||||
|
||||
await pool.query(
|
||||
'UPDATE songs SET audio_url = NULL WHERE id = $1',
|
||||
[audioFile.song_id]
|
||||
);
|
||||
|
||||
deleted++;
|
||||
console.log(`Deleted expired audio: ${audioFile.storage_key}`);
|
||||
} catch (err) {
|
||||
errors++;
|
||||
console.error(`Failed to delete audio ${audioFile.storage_key}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Cleanup complete: ${deleted} deleted, ${errors} errors`);
|
||||
return { deleted, errors };
|
||||
}
|
||||
|
||||
export async function cleanupDeletedSongs(): Promise<number> {
|
||||
const result = await pool.query(
|
||||
`DELETE FROM songs
|
||||
WHERE audio_url IS NULL
|
||||
AND created_at < NOW() - INTERVAL '7 days'
|
||||
RETURNING id`
|
||||
);
|
||||
|
||||
const count = result.rowCount || 0;
|
||||
if (count > 0) {
|
||||
console.log(`Cleaned up ${count} orphaned songs`);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { GenerationParams } from './acestep.js';
|
||||
|
||||
type Tier = 'free' | 'pro' | 'unlimited';
|
||||
|
||||
interface QueueJob {
|
||||
id: string;
|
||||
userId: string;
|
||||
tier: Tier;
|
||||
params: GenerationParams;
|
||||
createdAt: number;
|
||||
run: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface QueueConfig {
|
||||
maxTotalWorkers: number;
|
||||
maxFreeWorkers: number;
|
||||
maxPerUser: number;
|
||||
batchWindowMs: number;
|
||||
batchSize: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: QueueConfig = {
|
||||
maxTotalWorkers: 3,
|
||||
maxFreeWorkers: 1,
|
||||
maxPerUser: 1,
|
||||
batchWindowMs: 3000,
|
||||
batchSize: 4,
|
||||
};
|
||||
|
||||
class GenerationQueue {
|
||||
private queue: QueueJob[] = [];
|
||||
private activeJobs = new Map<string, QueueJob>();
|
||||
private activePerUser = new Map<string, number>();
|
||||
private activeFree = 0;
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
private config: QueueConfig;
|
||||
private persistEnqueue?: (jobId: string) => Promise<void>;
|
||||
private persistDequeue?: (jobId: string) => Promise<void>;
|
||||
|
||||
constructor(config: QueueConfig) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
setConfig(config: QueueConfig): void {
|
||||
this.config = config;
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
getConfig(): QueueConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
setPersistence(
|
||||
enqueue: (jobId: string) => Promise<void>,
|
||||
dequeue: (jobId: string) => Promise<void>
|
||||
): void {
|
||||
this.persistEnqueue = enqueue;
|
||||
this.persistDequeue = dequeue;
|
||||
}
|
||||
|
||||
enqueue(job: QueueJob, options?: { persist?: boolean }): { position: number } {
|
||||
this.queue.push(job);
|
||||
if (options?.persist !== false) {
|
||||
this.persistEnqueue?.(job.id).catch(() => {});
|
||||
}
|
||||
this.schedule();
|
||||
return { position: this.getQueuePosition(job.id) };
|
||||
}
|
||||
|
||||
getQueuePosition(jobId: string): number {
|
||||
const index = this.queue.findIndex((job) => job.id === jobId);
|
||||
return index === -1 ? 0 : index + 1;
|
||||
}
|
||||
|
||||
markJobFinished(jobId: string): void {
|
||||
const job = this.activeJobs.get(jobId);
|
||||
if (!job) return;
|
||||
this.activeJobs.delete(jobId);
|
||||
const userCount = (this.activePerUser.get(job.userId) || 1) - 1;
|
||||
if (userCount <= 0) this.activePerUser.delete(job.userId);
|
||||
else this.activePerUser.set(job.userId, userCount);
|
||||
if (job.tier === 'free') {
|
||||
this.activeFree = Math.max(0, this.activeFree - 1);
|
||||
}
|
||||
this.persistDequeue?.(jobId).catch(() => {});
|
||||
this.schedule();
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.timer) return;
|
||||
if (this.queue.length === 0) return;
|
||||
if (this.queue.length >= this.config.batchSize) {
|
||||
this.flush();
|
||||
return;
|
||||
}
|
||||
this.timer = setTimeout(() => this.flush(), this.config.batchWindowMs);
|
||||
}
|
||||
|
||||
private flush(): void {
|
||||
if (this.timer) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
|
||||
this.queue.sort((a, b) => this.calculatePriority(b) - this.calculatePriority(a));
|
||||
|
||||
while (this.queue.length > 0 && this.canDispatchAny()) {
|
||||
const index = this.queue.findIndex((job) => this.canDispatch(job));
|
||||
if (index === -1) break;
|
||||
const job = this.queue.splice(index, 1)[0];
|
||||
this.dispatch(job);
|
||||
}
|
||||
|
||||
if (this.queue.length > 0) {
|
||||
this.schedule();
|
||||
}
|
||||
}
|
||||
|
||||
private dispatch(job: QueueJob): void {
|
||||
this.activeJobs.set(job.id, job);
|
||||
this.activePerUser.set(job.userId, (this.activePerUser.get(job.userId) || 0) + 1);
|
||||
if (job.tier === 'free') {
|
||||
this.activeFree += 1;
|
||||
}
|
||||
|
||||
job.run().catch(() => {
|
||||
this.markJobFinished(job.id);
|
||||
});
|
||||
}
|
||||
|
||||
private calculatePriority(job: QueueJob): number {
|
||||
const tierWeight: Record<Tier, number> = { free: 1, pro: 5, unlimited: 10 };
|
||||
const waitMinutes = (Date.now() - job.createdAt) / 60000;
|
||||
return tierWeight[job.tier] + waitMinutes;
|
||||
}
|
||||
|
||||
private canDispatch(job: QueueJob): boolean {
|
||||
if (this.activeJobs.size >= this.config.maxTotalWorkers) return false;
|
||||
if (job.tier === 'free' && this.activeFree >= this.config.maxFreeWorkers) return false;
|
||||
const perUser = this.activePerUser.get(job.userId) || 0;
|
||||
if (perUser >= this.config.maxPerUser) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
private canDispatchAny(): boolean {
|
||||
return this.activeJobs.size < this.config.maxTotalWorkers;
|
||||
}
|
||||
}
|
||||
|
||||
export const generationQueue = new GenerationQueue(DEFAULT_CONFIG);
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { StorageProvider } from './index.js';
|
||||
import { LocalStorageProvider } from './local.js';
|
||||
|
||||
let storageInstance: StorageProvider | null = null;
|
||||
|
||||
export function getStorageProvider(): StorageProvider {
|
||||
if (storageInstance) {
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
// Always use local storage for ACE-Step UI
|
||||
console.log('Initializing local storage provider');
|
||||
storageInstance = new LocalStorageProvider();
|
||||
|
||||
return storageInstance;
|
||||
}
|
||||
|
||||
export function resetStorageProvider(): void {
|
||||
storageInstance = null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface StorageProvider {
|
||||
upload(key: string, data: Buffer, contentType: string): Promise<string>;
|
||||
getUrl(key: string, expiresIn?: number): Promise<string>;
|
||||
getPublicUrl(key: string): string;
|
||||
delete(key: string): Promise<void>;
|
||||
exists(key: string): Promise<boolean>;
|
||||
copy(sourceKey: string, destKey: string): Promise<void>;
|
||||
}
|
||||
|
||||
export type { StorageProvider as default };
|
||||
@@ -0,0 +1,60 @@
|
||||
import { writeFile, unlink, stat, mkdir, copyFile } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import type { StorageProvider } from './index.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const AUDIO_DIR = path.join(__dirname, '../../../public/audio');
|
||||
|
||||
export class LocalStorageProvider implements StorageProvider {
|
||||
private audioDir: string;
|
||||
|
||||
constructor() {
|
||||
this.audioDir = AUDIO_DIR;
|
||||
}
|
||||
|
||||
async upload(key: string, data: Buffer, _contentType: string): Promise<string> {
|
||||
const filepath = path.join(this.audioDir, key);
|
||||
await mkdir(path.dirname(filepath), { recursive: true });
|
||||
await writeFile(filepath, data);
|
||||
return `/audio/${key}`;
|
||||
}
|
||||
|
||||
async getUrl(key: string, _expiresIn?: number): Promise<string> {
|
||||
return `/audio/${key}`;
|
||||
}
|
||||
|
||||
getPublicUrl(key: string): string {
|
||||
return `/audio/${key}`;
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const filepath = path.join(this.audioDir, key);
|
||||
try {
|
||||
await unlink(filepath);
|
||||
} catch (err) {
|
||||
const error = err as NodeJS.ErrnoException;
|
||||
if (error.code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async exists(key: string): Promise<boolean> {
|
||||
const filepath = path.join(this.audioDir, key);
|
||||
try {
|
||||
await stat(filepath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async copy(sourceKey: string, destKey: string): Promise<void> {
|
||||
const sourcePath = path.join(this.audioDir, sourceKey);
|
||||
const destPath = path.join(this.audioDir, destKey);
|
||||
await mkdir(path.dirname(destPath), { recursive: true });
|
||||
await copyFile(sourcePath, destPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"strict": false,
|
||||
"noImplicitAny": false,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/scripts/**/*"]
|
||||
}
|
||||