Initial commit
commit
3138e96ae0
|
@ -0,0 +1,9 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = true
|
|
@ -0,0 +1,13 @@
|
|||
# python generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# venv
|
||||
.venv
|
||||
|
||||
src/data.db
|
||||
src/uploads
|
|
@ -0,0 +1 @@
|
|||
3.11.3
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"[python]": {
|
||||
"editor.defaultFormatter": "ms-python.black-formatter"
|
||||
},
|
||||
"python.formatting.provider": "none"
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
# demucs-server
|
||||
|
||||
Web service which lets you demix stems using remote compute resources.
|
||||
|
||||
## Setup
|
||||
|
||||
```shell
|
||||
$ rye sync
|
||||
$ rye shell
|
||||
[rye] $ cd src/
|
||||
src/ [rye] $ ./dev_run.py
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- flask application mostly lives in `demucs_server.routes`
|
||||
- demucs / ffmpeg is run on a separate thread at `demucs_server.torch_worker`
|
||||
- songs are queued with `torch_worker.work_queue.put(DemucsTask(..))`
|
||||
- songs are processed by the thread worker and the database is written to report progress
|
|
@ -0,0 +1,24 @@
|
|||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
|
||||
server_name demucs.stems.zip;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/demucs.stems.zip/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/demucs.stems.zip/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.2:8000/;
|
||||
}
|
||||
|
||||
location ^~ /uploads/ {
|
||||
gzip on;
|
||||
alias /path/to/demucs-server/src/uploads/;
|
||||
}
|
||||
|
||||
location = /sse {
|
||||
proxy_read_timeout 300;
|
||||
proxy_send_timeout 300;
|
||||
proxy_pass http://127.0.0.2:8000/;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
[project]
|
||||
name = "demucs-server"
|
||||
version = "0.1.0"
|
||||
description = "Add a short description here"
|
||||
dependencies = [
|
||||
"demucs>=4.0.0",
|
||||
"flask>=2.3.2",
|
||||
"gunicorn>=20.1.0",
|
||||
"colorama>=0.4.6",
|
||||
"ipython>=8.12.2",
|
||||
]
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.8"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.rye]
|
||||
managed = true
|
||||
dev-dependencies = [
|
||||
"black>=23.3.0",
|
||||
]
|
||||
|
||||
[tool.hatch.metadata]
|
||||
allow-direct-references = true
|
|
@ -0,0 +1,82 @@
|
|||
# generated by rye
|
||||
# use `rye lock` or `rye sync` to update this lockfile
|
||||
#
|
||||
# last locked with the following flags:
|
||||
# pre: false
|
||||
# features: []
|
||||
# all-features: false
|
||||
|
||||
-e file:.
|
||||
antlr4-python3-runtime==4.9.3
|
||||
asttokens==2.2.1
|
||||
backcall==0.2.0
|
||||
black==23.3.0
|
||||
blinker==1.6.2
|
||||
click==8.1.3
|
||||
cloudpickle==2.2.1
|
||||
cmake==3.26.4
|
||||
colorama==0.4.6
|
||||
cython==0.29.36
|
||||
decorator==5.1.1
|
||||
demucs==4.0.0
|
||||
diffq==0.2.4
|
||||
dora-search==0.1.12
|
||||
einops==0.6.1
|
||||
executing==1.2.0
|
||||
filelock==3.12.2
|
||||
flask==2.3.2
|
||||
gunicorn==20.1.0
|
||||
ipython==8.14.0
|
||||
itsdangerous==2.1.2
|
||||
jedi==0.18.2
|
||||
jinja2==3.1.2
|
||||
julius==0.2.7
|
||||
lameenc==1.5.1
|
||||
lit==16.0.6
|
||||
markupsafe==2.1.3
|
||||
matplotlib-inline==0.1.6
|
||||
mpmath==1.3.0
|
||||
mypy-extensions==1.0.0
|
||||
networkx==3.1
|
||||
numpy==1.25.0
|
||||
nvidia-cublas-cu11==11.10.3.66
|
||||
nvidia-cuda-cupti-cu11==11.7.101
|
||||
nvidia-cuda-nvrtc-cu11==11.7.99
|
||||
nvidia-cuda-runtime-cu11==11.7.99
|
||||
nvidia-cudnn-cu11==8.5.0.96
|
||||
nvidia-cufft-cu11==10.9.0.58
|
||||
nvidia-curand-cu11==10.2.10.91
|
||||
nvidia-cusolver-cu11==11.4.0.1
|
||||
nvidia-cusparse-cu11==11.7.4.91
|
||||
nvidia-nccl-cu11==2.14.3
|
||||
nvidia-nvtx-cu11==11.7.91
|
||||
omegaconf==2.3.0
|
||||
openunmix==1.2.1
|
||||
packaging==23.1
|
||||
parso==0.8.3
|
||||
pathspec==0.11.1
|
||||
pexpect==4.8.0
|
||||
pickleshare==0.7.5
|
||||
platformdirs==3.8.0
|
||||
prompt-toolkit==3.0.39
|
||||
ptyprocess==0.7.0
|
||||
pure-eval==0.2.2
|
||||
pygments==2.15.1
|
||||
pyyaml==6.0
|
||||
retrying==1.3.4
|
||||
six==1.16.0
|
||||
stack-data==0.6.2
|
||||
submitit==1.4.5
|
||||
sympy==1.12
|
||||
torch==2.0.1
|
||||
torchaudio==2.0.2
|
||||
tqdm==4.65.0
|
||||
traitlets==5.9.0
|
||||
treetable==0.2.5
|
||||
triton==2.0.0
|
||||
typing-extensions==4.7.1
|
||||
wcwidth==0.2.6
|
||||
werkzeug==2.3.6
|
||||
wheel==0.40.0
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
setuptools==68.0.0
|
|
@ -0,0 +1,77 @@
|
|||
# generated by rye
|
||||
# use `rye lock` or `rye sync` to update this lockfile
|
||||
#
|
||||
# last locked with the following flags:
|
||||
# pre: false
|
||||
# features: []
|
||||
# all-features: false
|
||||
|
||||
-e file:.
|
||||
antlr4-python3-runtime==4.9.3
|
||||
asttokens==2.2.1
|
||||
backcall==0.2.0
|
||||
blinker==1.6.2
|
||||
click==8.1.3
|
||||
cloudpickle==2.2.1
|
||||
cmake==3.26.4
|
||||
colorama==0.4.6
|
||||
cython==0.29.36
|
||||
decorator==5.1.1
|
||||
demucs==4.0.0
|
||||
diffq==0.2.4
|
||||
dora-search==0.1.12
|
||||
einops==0.6.1
|
||||
executing==1.2.0
|
||||
filelock==3.12.2
|
||||
flask==2.3.2
|
||||
gunicorn==20.1.0
|
||||
ipython==8.14.0
|
||||
itsdangerous==2.1.2
|
||||
jedi==0.18.2
|
||||
jinja2==3.1.2
|
||||
julius==0.2.7
|
||||
lameenc==1.5.1
|
||||
lit==16.0.6
|
||||
markupsafe==2.1.3
|
||||
matplotlib-inline==0.1.6
|
||||
mpmath==1.3.0
|
||||
networkx==3.1
|
||||
numpy==1.25.0
|
||||
nvidia-cublas-cu11==11.10.3.66
|
||||
nvidia-cuda-cupti-cu11==11.7.101
|
||||
nvidia-cuda-nvrtc-cu11==11.7.99
|
||||
nvidia-cuda-runtime-cu11==11.7.99
|
||||
nvidia-cudnn-cu11==8.5.0.96
|
||||
nvidia-cufft-cu11==10.9.0.58
|
||||
nvidia-curand-cu11==10.2.10.91
|
||||
nvidia-cusolver-cu11==11.4.0.1
|
||||
nvidia-cusparse-cu11==11.7.4.91
|
||||
nvidia-nccl-cu11==2.14.3
|
||||
nvidia-nvtx-cu11==11.7.91
|
||||
omegaconf==2.3.0
|
||||
openunmix==1.2.1
|
||||
parso==0.8.3
|
||||
pexpect==4.8.0
|
||||
pickleshare==0.7.5
|
||||
prompt-toolkit==3.0.39
|
||||
ptyprocess==0.7.0
|
||||
pure-eval==0.2.2
|
||||
pygments==2.15.1
|
||||
pyyaml==6.0
|
||||
retrying==1.3.4
|
||||
six==1.16.0
|
||||
stack-data==0.6.2
|
||||
submitit==1.4.5
|
||||
sympy==1.12
|
||||
torch==2.0.1
|
||||
torchaudio==2.0.2
|
||||
tqdm==4.65.0
|
||||
traitlets==5.9.0
|
||||
treetable==0.2.5
|
||||
triton==2.0.0
|
||||
typing-extensions==4.7.1
|
||||
wcwidth==0.2.6
|
||||
werkzeug==2.3.6
|
||||
wheel==0.40.0
|
||||
# The following packages are considered to be unsafe in a requirements file:
|
||||
setuptools==68.0.0
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
import demucs_server
|
||||
|
||||
import uuid
|
||||
import IPython
|
||||
|
||||
with demucs_server.app.app_context():
|
||||
|
||||
def create_user(name: str) -> str:
|
||||
db = demucs_server.db.get_db()
|
||||
user_id = str(uuid.uuid4())
|
||||
db.execute(
|
||||
"INSERT INTO users (user_id, nickname) VALUES (?, ?)", [user_id, name]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
return user_id
|
||||
|
||||
def list_users() -> list["demucs_server.auth.User"]:
|
||||
from demucs_server.auth import User
|
||||
|
||||
db = demucs_server.db.get_db()
|
||||
users = []
|
||||
cur = db.execute("SELECT * FROM users")
|
||||
for user_id, nickname in cur.fetchall():
|
||||
users.append(User(uuid.UUID(user_id), nickname))
|
||||
cur.close()
|
||||
|
||||
return users
|
||||
|
||||
IPython.embed()
|
|
@ -0,0 +1,22 @@
|
|||
from flask import Flask
|
||||
import colorama
|
||||
from pathlib import Path
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
colorama.init()
|
||||
UPLOAD_DIR = Path(".") / "uploads"
|
||||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||||
|
||||
from . import db
|
||||
|
||||
with app.app_context():
|
||||
from .migrations import apply_migrations
|
||||
|
||||
apply_migrations()
|
||||
|
||||
from . import routes
|
||||
|
||||
from .torch_worker import start_worker
|
||||
|
||||
start_worker()
|
|
@ -0,0 +1,25 @@
|
|||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from .db import get_db
|
||||
|
||||
|
||||
@dataclass
|
||||
class User:
|
||||
user_id: UUID
|
||||
nickname: str
|
||||
|
||||
|
||||
def get_user(user_id: str) -> User | None:
|
||||
try:
|
||||
user_id = UUID(user_id)
|
||||
except:
|
||||
return None
|
||||
|
||||
db = get_db()
|
||||
cur = db.execute("SELECT * FROM users WHERE user_id = ?", [str(user_id)])
|
||||
user_row = cur.fetchone()
|
||||
if user_row is None:
|
||||
return None
|
||||
|
||||
return User(UUID(user_row[0]), user_row[1])
|
|
@ -0,0 +1,22 @@
|
|||
import sqlite3
|
||||
from flask import g
|
||||
|
||||
import os
|
||||
|
||||
DATABASE = os.environ.get("DATABASE_PATH") or "data.db"
|
||||
|
||||
from . import app
|
||||
|
||||
|
||||
def get_db():
|
||||
db = getattr(g, "_database", None)
|
||||
if db is None:
|
||||
db = g._database = sqlite3.connect(DATABASE)
|
||||
return db
|
||||
|
||||
|
||||
@app.teardown_appcontext
|
||||
def close_connection(exception):
|
||||
db = getattr(g, "_database", None)
|
||||
if db is not None:
|
||||
db.close()
|
|
@ -0,0 +1,29 @@
|
|||
from .db import get_db
|
||||
from pathlib import Path
|
||||
|
||||
import colorama
|
||||
|
||||
|
||||
def apply_migrations():
|
||||
migrations_dir = Path("./migrations")
|
||||
if not migrations_dir.exists():
|
||||
raise Exception("migrations directory was not found")
|
||||
|
||||
db = get_db()
|
||||
already_run_migrations = set([])
|
||||
try:
|
||||
cur = db.execute("SELECT applied FROM migrations")
|
||||
for (applied,) in cur.fetchall():
|
||||
already_run_migrations.add(applied)
|
||||
except:
|
||||
pass
|
||||
|
||||
for migration in sorted(migrations_dir.iterdir()):
|
||||
if migration.name not in already_run_migrations:
|
||||
print(
|
||||
f"{colorama.Fore.LIGHTBLUE_EX}[*]{colorama.Fore.RESET} "
|
||||
f"Applied migration: {colorama.Fore.GREEN}{migration.name}{colorama.Fore.RESET}"
|
||||
)
|
||||
db.executescript(migration.read_text())
|
||||
db.execute("INSERT INTO migrations VALUES (?)", [migration.name])
|
||||
db.commit()
|
|
@ -0,0 +1,168 @@
|
|||
from flask import render_template, request, redirect, send_file, Response
|
||||
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.security import safe_join
|
||||
|
||||
from uuid import uuid4
|
||||
import json
|
||||
|
||||
from . import app, UPLOAD_DIR
|
||||
from .auth import get_user
|
||||
from .db import get_db
|
||||
from .torch_worker import get_device_name, get_queue_size, work_queue, DemucsTask
|
||||
from .sse import bus
|
||||
|
||||
|
||||
@app.route("/")
|
||||
@app.route("/from-url")
|
||||
def index_page():
|
||||
user_id = request.cookies.get("demucs_server_user_id")
|
||||
user = user_id and get_user(user_id)
|
||||
if user is None:
|
||||
return render_template("login.html.j2")
|
||||
|
||||
status = {"device": get_device_name(), "queue_size": get_queue_size()}
|
||||
|
||||
if request.path == "/from-url":
|
||||
return render_template("demix_ytdlp.html.j2", user=user, status=status)
|
||||
return render_template("demix.html.j2", user=user, status=status)
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
response = redirect("/")
|
||||
user_id = request.form.get("user-id", type=str)
|
||||
if user_id is not None:
|
||||
response.set_cookie(
|
||||
"demucs_server_user_id",
|
||||
user_id,
|
||||
samesite="Strict",
|
||||
secure=True,
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@app.route("/logout", methods=["GET", "POST"])
|
||||
def logout():
|
||||
if request.method == "POST":
|
||||
response = redirect("/")
|
||||
response.delete_cookie("demucs_server_user_id")
|
||||
return response
|
||||
return render_template("logout.html.j2")
|
||||
|
||||
|
||||
# TODO: demix routes (involves multipart form parsing!! maybe ytdlp!!)
|
||||
@app.route("/demix", methods=["POST"])
|
||||
def demix():
|
||||
user_id = request.cookies.get("demucs_server_user_id")
|
||||
user = user_id and get_user(user_id)
|
||||
if user is None:
|
||||
return "You are not logged in!", 403
|
||||
|
||||
audio_file = next(request.files.values(), None)
|
||||
if audio_file is None:
|
||||
return redirect("/")
|
||||
|
||||
orig_name = secure_filename(audio_file.filename).replace("_", "-")
|
||||
task_id = uuid4()
|
||||
song_location = f"{task_id}-{orig_name}"
|
||||
orig_name = orig_name.rsplit(".", 1)[0]
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO tasks (id, orig_name, status, song_location) VALUES (?, ?, ?, ?)",
|
||||
[str(task_id), orig_name, "queued", song_location],
|
||||
)
|
||||
audio_file.save(UPLOAD_DIR / song_location)
|
||||
db.commit()
|
||||
|
||||
demucs_task = DemucsTask(task_id, orig_name, song_location)
|
||||
work_queue.put(demucs_task)
|
||||
|
||||
return redirect(f"/task/{task_id}")
|
||||
|
||||
|
||||
@app.route("/task/<task_id>")
|
||||
def task_view(task_id):
|
||||
db = get_db()
|
||||
|
||||
try:
|
||||
cur = db.execute("SELECT status, orig_name FROM tasks WHERE id = ?", [task_id])
|
||||
(status, orig_name) = cur.fetchone()
|
||||
except:
|
||||
# TODO: real error page
|
||||
return "Task with the given ID was not found", 404
|
||||
|
||||
outputs = []
|
||||
try:
|
||||
cur = db.execute(
|
||||
"SELECT track, output_path FROM task_outputs WHERE task_id = ?", [task_id]
|
||||
)
|
||||
outputs = [
|
||||
{"track": track, "path": path}
|
||||
for (
|
||||
track,
|
||||
path,
|
||||
) in cur.fetchall()
|
||||
]
|
||||
except:
|
||||
pass
|
||||
|
||||
outputs = sorted(
|
||||
outputs,
|
||||
key=lambda o: ["master", "vocals", "other", "bass", "drums"].index(o["track"]),
|
||||
)
|
||||
|
||||
progress = {}
|
||||
try:
|
||||
cur = db.execute(
|
||||
"SELECT stage, stages, step, stage_steps FROM task_progress WHERE task_id = ?",
|
||||
[task_id],
|
||||
)
|
||||
stage, stages, step, stage_steps = cur.fetchone()
|
||||
progress = {
|
||||
"stage": stage,
|
||||
"stages": stages,
|
||||
"step": step,
|
||||
"stage-steps": stage_steps,
|
||||
}
|
||||
except:
|
||||
pass
|
||||
|
||||
return render_template(
|
||||
"task.html.j2",
|
||||
task_id=task_id,
|
||||
status=status,
|
||||
orig_name=orig_name,
|
||||
outputs=outputs,
|
||||
progress=progress,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/uploads/<path:upload>")
|
||||
def send_upload(upload):
|
||||
upload_file = safe_join(UPLOAD_DIR.absolute(), upload)
|
||||
return send_file(upload_file, conditional=True)
|
||||
|
||||
|
||||
@app.route("/sse")
|
||||
def sse():
|
||||
task_id = request.args.get("task_id")
|
||||
if task_id is None:
|
||||
return "bad request", 400
|
||||
|
||||
def stream():
|
||||
queue = bus.listen()
|
||||
while True:
|
||||
message = queue.get()
|
||||
if message.get("task") != task_id:
|
||||
continue
|
||||
message = json.dumps(message)
|
||||
yield f"{message}\n\n"
|
||||
|
||||
return Response(
|
||||
stream(),
|
||||
mimetype="text/event-stream",
|
||||
headers={"cache-control": "no-cache", "x-accel-buffering": "no"},
|
||||
)
|
|
@ -0,0 +1,21 @@
|
|||
import queue
|
||||
|
||||
|
||||
class AnnounceBus:
|
||||
def __init__(self):
|
||||
self.listeners = []
|
||||
|
||||
def listen(self):
|
||||
q = queue.Queue(maxsize=5)
|
||||
self.listeners.append(q)
|
||||
return q
|
||||
|
||||
def announce(self, msg):
|
||||
for i in reversed(range(len(self.listeners))):
|
||||
try:
|
||||
self.listeners[i].put_nowait(msg)
|
||||
except queue.Full:
|
||||
del self.listeners[i]
|
||||
|
||||
|
||||
bus = AnnounceBus()
|
|
@ -0,0 +1,265 @@
|
|||
:root {
|
||||
--col-bg: #25181b;
|
||||
--col-bg-dark: #1e1417;
|
||||
--col-bg-light: #331f23;
|
||||
--col-accent: #44cf6c;
|
||||
--col-accent-dark: #32a287;
|
||||
--col-accent-light: #a9fdac;
|
||||
--col-fg: #f5fff2;
|
||||
|
||||
--font-main: "Inter", "Arial", sans-serif;
|
||||
--font-mono: ui-monospace, "Cascadia Mono", "Segoe UI Mono", "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
font-family: var(--font-main);
|
||||
font-size: 1.25rem;
|
||||
color: var(--col-fg);
|
||||
background-color: var(--col-bg);
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
width: calc(100% - 1em);
|
||||
max-width: 80ch;
|
||||
margin: 0 auto;
|
||||
padding: 0.5em;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--col-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
border-bottom: 1px solid var(--col-accent);
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: inline;
|
||||
height: 3.5em;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1em;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
text-align: center;
|
||||
margin-top: 0.25em;
|
||||
}
|
||||
|
||||
header.vertical {
|
||||
flex-direction: column;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
margin: 0;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
header + h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
header small {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
padding: 1em;
|
||||
border: 2.5px solid var(--col-accent-dark);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
form :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
form :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
input {
|
||||
background-color: transparent;
|
||||
border: 1.5px solid var(--col-accent-light);
|
||||
outline: none;
|
||||
border-radius: 8px;
|
||||
|
||||
font-family: var(--font-main);
|
||||
font-size: 1em;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
|
||||
box-shadow: none;
|
||||
width: calc(100% - 2rem);
|
||||
|
||||
color: var(--col-fg);
|
||||
}
|
||||
|
||||
.file-upload {
|
||||
margin: 0.75rem 0;
|
||||
overflow-x: clip;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
input[type="url"] {
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
|
||||
.file-upload + p,
|
||||
input[type="url"] + p {
|
||||
margin-top: 0.25em;
|
||||
padding-left: 1.5em;
|
||||
}
|
||||
|
||||
input:active,
|
||||
input:focus {
|
||||
border-color: var(--col-accent);
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button,
|
||||
.file-upload-button {
|
||||
font-family: var(--font-main);
|
||||
font-size: 1em;
|
||||
|
||||
background-color: var(--col-accent-light);
|
||||
border: 1.5px solid var(--col-accent-light);
|
||||
border-radius: 8px;
|
||||
|
||||
outline: none;
|
||||
color: var(--col-bg);
|
||||
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.file-upload-button {
|
||||
margin-right: 0.5em;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
background-color: var(--col-accent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
opacity: 0.33;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
dl {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5ch;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
dt::after {
|
||||
content: ":";
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
padding: 1em;
|
||||
background-color: var(--col-bg-dark);
|
||||
}
|
||||
|
||||
nav ul {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 0.5em;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
nav ul li {
|
||||
display: table-cell;
|
||||
padding: 0.1em 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
nav ul li + li {
|
||||
border-inline-start: 1px solid var(--col-fg);
|
||||
padding-inline-start: 0.5em;
|
||||
}
|
||||
|
||||
.track-list {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.track-list li {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1em;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.track-list a {
|
||||
display: inline-block;
|
||||
padding: 0.5em 0.5em;
|
||||
margin-right: 1em;
|
||||
border: 1px solid var(--col-accent);
|
||||
border-radius: 4px;
|
||||
width: 8em;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.track-list a:hover {
|
||||
background: var(--col-accent);
|
||||
color: var(--col-bg);
|
||||
}
|
||||
|
||||
.track-list audio {
|
||||
flex: 1;
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<title>demucs.stems.zip</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main>
|
||||
<header>
|
||||
<h1>demucs.stems.zip</h1>
|
||||
</header>
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</body>
|
||||
|
||||
</html>
|
|
@ -0,0 +1,41 @@
|
|||
{% extends "_base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>demix song</h2>
|
||||
<p>
|
||||
use <a href="https://github.com/facebookresearch/demucs">demucs</a>
|
||||
to generate 'vocal', 'bass', 'drums', and 'other' tracks:
|
||||
</p>
|
||||
|
||||
{% block form %}
|
||||
<form action="/demix" method="POST" enctype="multipart/form-data">
|
||||
<br>
|
||||
|
||||
<label class="file-upload">
|
||||
<span class="file-upload-button">browse...</span>
|
||||
<span class="file-upload-status">upload audio file (.aic, .aif, .flac, .m4a, .mp3, .ogg, .opus, .wav)</span>
|
||||
<input type="file" name="song" accept="audio/*" required />
|
||||
</label>
|
||||
|
||||
<!-- NYI TODO: <p>or <a href="/from-url">download from online</a> (e.g. youtube).</p> -->
|
||||
|
||||
<button>enqueue</button>
|
||||
|
||||
<ul class="status">
|
||||
<li>songs in queue: <strong>{{ status['queue_size'] }}</strong></li>
|
||||
<li>this node is using: <strong>{{ status['device'] }}</strong></li>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
const fileInput = document.querySelector(".file-upload input[type=file]")
|
||||
fileInput.addEventListener("change", e => {
|
||||
if (fileInput.files.length > 0) {
|
||||
document.querySelector(".file-upload-status").textContent = `selected: ${fileInput.files[0].name}`
|
||||
} else {
|
||||
document.querySelector(".file-upload-status").textContent = `no file selected.`
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
{% endblock %}
|
|
@ -0,0 +1,19 @@
|
|||
{% extends "demix.html.j2" %}
|
||||
|
||||
{% block form %}
|
||||
<form action="/demix-ytlp" method="POST" enctype="multipart/form-data">
|
||||
<br>
|
||||
|
||||
<label for="url">song url:</label>
|
||||
<input type="url" id="url" name="url" placeholder="https://youtu.be/…" />
|
||||
|
||||
<p>or <a href="/">upload an audio file</a>.</p>
|
||||
|
||||
<button>enqueue</button>
|
||||
|
||||
<ul class="status">
|
||||
<li>songs in queue: <strong>{{ status['queue_size'] }}</strong></li>
|
||||
<li>this node is using: <strong>{{ status['device'] }}</strong></li>
|
||||
</ul>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -0,0 +1,12 @@
|
|||
{% extends "_base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>log in</h2>
|
||||
|
||||
<form action="/login" method="POST">
|
||||
<label for="user-id">user id:</label>
|
||||
<input type="password" name="user-id" id="user-id" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" required />
|
||||
<button>submit</button>
|
||||
<p><code>demucs.stems.zip</code> is <em>invite-only</em>.</p>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -0,0 +1,9 @@
|
|||
{% extends "_base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<form action="/logout" method="POST">
|
||||
<h2>are you sure you want to log out?</h2>
|
||||
<button>log out</button>
|
||||
<p>you will need your user ID to log back in.</p>
|
||||
</form>
|
||||
{% endblock %}
|
|
@ -0,0 +1,61 @@
|
|||
{% extends "_base.html.j2" %}
|
||||
|
||||
{% block content %}
|
||||
<h2>task</h2>
|
||||
|
||||
{% if status in ["queued"] %}
|
||||
<p>your upload is currently in the queue, and hasn't started processing yet.</p>
|
||||
{% elif status in ["in progress", "saving"] %}
|
||||
<p>your upload is currently processing.</p>
|
||||
{% endif %}
|
||||
|
||||
<p>status: <strong class="status">{{ status }}</strong></p>
|
||||
<p>originally: <strong>{{ orig_name }}</strong></p>
|
||||
|
||||
<section class="progress-section" {% if status !="in progress" %}style="display: none" {% endif %}>
|
||||
<h3>progress</h3>
|
||||
<p>
|
||||
stage:
|
||||
<data id="stage">{{ progress.get('stage') or '' }}</data> /
|
||||
<data id="stages">{{ progress.get('stages') or '' }}</data>
|
||||
</p>
|
||||
<p>
|
||||
step:
|
||||
<data id="step">{{ progress.get('step') or '' }}</data> /
|
||||
<data id="stage-steps">{{ progress.get('stage-steps') or '' }}</data>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const source = new EventSource("/sse?task_id={{ task_id }}")
|
||||
source.onmessage = (event) => {
|
||||
console.log(event);
|
||||
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.status != null) {
|
||||
document.querySelector("#status").textContent = message.status;
|
||||
}
|
||||
|
||||
if (message.progress != null) {
|
||||
document.querySelector("progress-section").style.display = "unset";
|
||||
for (const item in message.progress) {
|
||||
document.getElementById(item).textContent = message.progress[item];
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{% if outputs %}
|
||||
<ul class="track-list">
|
||||
{% for output in outputs %}
|
||||
<li>
|
||||
<a href="/{{ output['path'] }}">{{ output['track'] }}</a>
|
||||
<audio controls>
|
||||
<source src="/{{ output['path'] }}" />
|
||||
</audio>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
|
@ -0,0 +1,203 @@
|
|||
import torch
|
||||
import queue
|
||||
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from . import UPLOAD_DIR
|
||||
from .db import DATABASE
|
||||
from .sse import bus
|
||||
|
||||
import sqlite3
|
||||
|
||||
|
||||
@dataclass
|
||||
class DemucsTask:
|
||||
database_id: UUID
|
||||
orig_name: str
|
||||
song_location: str
|
||||
|
||||
|
||||
work_queue: queue.Queue[DemucsTask] = queue.Queue()
|
||||
|
||||
|
||||
def get_device_name() -> str:
|
||||
if torch.cuda.is_available():
|
||||
return f"{torch.cuda.get_device_name()} (CUDA)"
|
||||
|
||||
try:
|
||||
cpu_info = Path("/proc/cpuinfo").read_text().splitlines()
|
||||
cpu_info = [l.split(":") for l in cpu_info]
|
||||
cpu_info = [l for l in cpu_info if l[0].strip() == "model name"]
|
||||
cpu_info = [l[1].strip() for l in cpu_info][0]
|
||||
return f"{cpu_info} (CPU)"
|
||||
except:
|
||||
return "Unknown CPU"
|
||||
|
||||
|
||||
def get_queue_size() -> int:
|
||||
return work_queue.unfinished_tasks
|
||||
|
||||
|
||||
def report_task_status(task_id: UUID, status: str):
|
||||
bus.announce({"task": str(task_id), "status": status})
|
||||
# short-lived sqlite connections since we spend most of the time
|
||||
# on the worker thread just waiting for stuff
|
||||
with sqlite3.connect(DATABASE) as conn:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET status = ? WHERE id = ?",
|
||||
[status, str(task_id)],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def report_task_output(task_id: UUID, track: str, output_path: Path):
|
||||
bus.announce({"task": str(task_id), "output": track})
|
||||
# short-lived sqlite connections since we spend most of the time
|
||||
# on the worker thread just waiting for stuff
|
||||
with sqlite3.connect(DATABASE) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO task_outputs (task_id, track, output_path) VALUES (?, ?, ?)",
|
||||
[str(task_id), track, str(output_path)],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def report_task_progress(
|
||||
task_id: UUID, stage: int, stages: int, step: int, stage_steps: int
|
||||
):
|
||||
bus.announce(
|
||||
{
|
||||
"task": str(task_id),
|
||||
"progress": {
|
||||
"stage": stage,
|
||||
"stages": stages,
|
||||
"step": step,
|
||||
"stage-steps": stage_steps,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with sqlite3.connect(DATABASE) as conn:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO task_progress (task_id, stage, stages, step, stage_steps) VALUES (?, ?, ?, ?, ?)",
|
||||
[str(task_id), stage, stages, step, stage_steps],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
from demucs.pretrained import get_model
|
||||
from demucs.apply import apply_model, tqdm, BagOfModels
|
||||
from demucs.separate import load_track
|
||||
from demucs.audio import save_audio
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def convert_audio(source: Path, output: Path):
|
||||
command = ["ffmpeg", "-y", "-loglevel", "panic"]
|
||||
command += ["-i", str(source)]
|
||||
command += [str(output)]
|
||||
subprocess.run(command, check=True)
|
||||
|
||||
|
||||
def process_task(task: DemucsTask, model):
|
||||
task_id = task.database_id
|
||||
|
||||
song_path = UPLOAD_DIR / task.song_location
|
||||
|
||||
task_dir = UPLOAD_DIR / str(task.database_id)
|
||||
task_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
master_path = task_dir / f"{task.orig_name}-master.flac"
|
||||
convert_audio(song_path, master_path)
|
||||
report_task_output(task.database_id, "master", master_path)
|
||||
|
||||
wav = load_track(song_path, model.audio_channels, model.samplerate)
|
||||
|
||||
ref = wav.mean(0)
|
||||
wav -= ref.mean()
|
||||
wav /= ref.std()
|
||||
|
||||
device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
|
||||
# hook the demucs progress function
|
||||
stages = 1
|
||||
if isinstance(model, BagOfModels):
|
||||
stages = len(model.models)
|
||||
stage = 0
|
||||
|
||||
def progress(futures, **kwargs):
|
||||
nonlocal stage, task_id
|
||||
|
||||
stage += 1
|
||||
stage_steps = len(futures)
|
||||
|
||||
for step, future in enumerate(futures, start=1):
|
||||
report_task_progress(task_id, stage, stages, step, stage_steps)
|
||||
yield future
|
||||
|
||||
tqdm.tqdm = progress
|
||||
sources = apply_model(
|
||||
model,
|
||||
wav[None],
|
||||
device=device,
|
||||
shifts=1,
|
||||
split=True,
|
||||
overlap=0.25,
|
||||
progress=True,
|
||||
num_workers=0,
|
||||
)[0]
|
||||
|
||||
# denormalize amplitude / DC
|
||||
sources *= ref.std()
|
||||
sources += ref.mean()
|
||||
|
||||
report_task_status(task.database_id, "saving")
|
||||
|
||||
outputs = {}
|
||||
for source, source_name in zip(sources, model.sources):
|
||||
path = song_path.parent / f"{song_path.name}.{source_name}.wav"
|
||||
save_audio(source, str(path), samplerate=model.samplerate)
|
||||
outputs[source_name] = path
|
||||
|
||||
song_path.unlink()
|
||||
|
||||
for source, path in sorted(outputs.items()):
|
||||
flac_path = task_dir / f"{task.orig_name}-{source}.flac"
|
||||
convert_audio(path, flac_path)
|
||||
report_task_output(task.database_id, source, flac_path)
|
||||
path.unlink() # delete wav
|
||||
|
||||
|
||||
def worker_main():
|
||||
model = get_model("htdemucs_ft")
|
||||
model.eval()
|
||||
|
||||
with sqlite3.connect(DATABASE) as conn:
|
||||
cur = conn.execute(
|
||||
"SELECT id, orig_name, song_location FROM tasks WHERE status NOT IN ('done', 'errored')"
|
||||
)
|
||||
for id, orig_name, song_location in cur.fetchall():
|
||||
work_queue.put(DemucsTask(UUID(id), orig_name, song_location))
|
||||
|
||||
while True:
|
||||
task = work_queue.get()
|
||||
try:
|
||||
report_task_status(task.database_id, "in progress")
|
||||
process_task(task, model)
|
||||
report_task_status(task.database_id, "done")
|
||||
except BaseException as e:
|
||||
report_task_status(task.database_id, "errored")
|
||||
print(e)
|
||||
work_queue.task_done()
|
||||
|
||||
|
||||
def start_worker():
|
||||
import threading
|
||||
|
||||
thread = threading.Thread(target=worker_main, daemon=True)
|
||||
thread.start()
|
||||
|
||||
return thread
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env python
|
||||
|
||||
from demucs_server import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(port=8000, debug=True)
|
|
@ -0,0 +1,5 @@
|
|||
CREATE TABLE migrations (
|
||||
applied TEXT PRIMARY KEY
|
||||
) STRICT;
|
||||
|
||||
INSERT INTO migrations VALUES ('0000_init');
|
|
@ -0,0 +1,4 @@
|
|||
CREATE TABLE users (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
nickname TEXT NOT NULL
|
||||
) STRICT;
|
|
@ -0,0 +1,6 @@
|
|||
CREATE TABLE tasks (
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
orig_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL, -- "queued / in progress / done"
|
||||
song_location TEXT NOT NULL
|
||||
) STRICT;
|
|
@ -0,0 +1,5 @@
|
|||
CREATE TABLE task_outputs (
|
||||
task_id TEXT NOT NULL,
|
||||
track TEXT NOT NULL,
|
||||
output_path TEXT NOT NULL
|
||||
) STRICT;
|
|
@ -0,0 +1,7 @@
|
|||
CREATE TABLE task_progress (
|
||||
task_id TEXT NOT NULL UNIQUE,
|
||||
stage INTEGER NOT NULL,
|
||||
stages INTEGER NOT NULL,
|
||||
step INTEGER NOT NULL,
|
||||
stage_steps INTEGER NOT NULL
|
||||
) STRICT;
|
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env sh
|
||||
|
||||
gunicorn -b '127.0.0.2:8000' demucs_server:app
|
Loading…
Reference in New Issue