├── .dockerignore ├── .eslintignore ├── .github ├── FUNDING.yml ├── dependabot.yml └── workflows │ ├── automerge.yml │ ├── master.yml │ └── release.yml ├── .gitignore ├── .style.yapf ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── api-entrypoint.sh ├── api ├── __init__.py ├── admin.py ├── apps.py ├── celery.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20201218_0343.py │ ├── 0003_auto_20201229_0321.py │ ├── 0004_auto_20210107_0131.py │ ├── 0005_auto_20210526_0244.py │ ├── 0006_auto_20210614_1554.py │ ├── 0007_auto_20210621_0554.py │ ├── 0008_auto_20211217_0122.py │ ├── 0009_auto_20220222_0232.py │ ├── 0010_alter_dynamicmix_separator_alter_staticmix_separator.py │ ├── 0011_alter_dynamicmix_bitrate_alter_staticmix_bitrate.py │ ├── 0012_alter_staticmix_unique_together_and_more.py │ └── __init__.py ├── models.py ├── separators │ ├── d3net_openvino.py │ ├── d3net_separator.py │ ├── demucs_separator.py │ ├── spleeter_separator.py │ ├── util.py │ └── x_umx_separator.py ├── serializers.py ├── setup.cfg ├── signals.py ├── storage.py ├── tasks.py ├── urls.py ├── util.py ├── validators.py ├── views.py ├── youtube_search.py └── youtubedl.py ├── celery-fast-entrypoint.sh ├── celery-slow-entrypoint.sh ├── config ├── 4stems-16kHz.json ├── 5stems-16kHz.json └── d3net │ ├── bass.yaml │ ├── drums.yaml │ ├── other.yaml │ └── vocals.yaml ├── django_react ├── __init__.py ├── asgi.py ├── middleware.py ├── settings.py ├── settings_dev.py ├── settings_docker.py ├── settings_docker_dev.py ├── urls.py └── wsgi.py ├── docker-compose.build.gpu.yml ├── docker-compose.build.yml ├── docker-compose.dev.yml ├── docker-compose.gpu.yml ├── docker-compose.https.yml ├── docker-compose.prod.selfhost.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── frontend ├── .babelrc ├── .dockerignore ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── Dockerfile ├── __init__.py ├── apps.py ├── context_processors.py ├── frontend-entrypoint.sh ├── package-lock.json ├── package.json ├── src │ ├── Constants.tsx │ ├── Utils.tsx │ ├── components │ │ ├── Badges.tsx │ │ ├── Home │ │ │ ├── AutoRefreshButton.tsx │ │ │ ├── Home.css │ │ │ ├── Home.tsx │ │ │ ├── MusicPlayer.css │ │ │ └── MusicPlayer.tsx │ │ ├── Mixer │ │ │ ├── CancelButton.tsx │ │ │ ├── CancelTaskModal.tsx │ │ │ ├── DeleteButton.tsx │ │ │ ├── DeleteTaskModal.tsx │ │ │ ├── ExportButton.tsx │ │ │ ├── ExportForm.tsx │ │ │ ├── ExportModal.css │ │ │ ├── ExportModal.tsx │ │ │ ├── Mixer.tsx │ │ │ ├── MixerPlayer.css │ │ │ ├── MixerPlayer.tsx │ │ │ ├── MuteButton.tsx │ │ │ ├── PlayButton.tsx │ │ │ ├── PlayerUI.css │ │ │ ├── PlayerUI.tsx │ │ │ ├── SoloButton.tsx │ │ │ ├── VolumeUI.css │ │ │ └── VolumeUI.tsx │ │ ├── Nav │ │ │ ├── HomeNavBar.tsx │ │ │ └── PlainNavBar.tsx │ │ ├── NotFound.tsx │ │ ├── SongTable │ │ │ ├── Button │ │ │ │ ├── DeleteDynamicMixButton.tsx │ │ │ │ ├── DeleteStaticMixButton.tsx │ │ │ │ ├── DeleteTrackButton.tsx │ │ │ │ ├── PausePlayButton.tsx │ │ │ │ ├── PlayMixButton.tsx │ │ │ │ ├── RecordPlayer.tsx │ │ │ │ ├── TextButton.css │ │ │ │ └── TextButton.tsx │ │ │ ├── Form │ │ │ │ ├── DynamicMixModalForm.tsx │ │ │ │ ├── MixModalForm.css │ │ │ │ ├── SeparatorFormGroup.tsx │ │ │ │ ├── SongInfoFormGroup.tsx │ │ │ │ ├── StaticMixModalForm.tsx │ │ │ │ └── XUMXFormSubgroup.tsx │ │ │ ├── MixTable.css │ │ │ ├── MixTable.tsx │ │ │ ├── Modal │ │ │ │ ├── DeleteDynamicMixModal.tsx │ │ │ │ ├── DeleteStaticMixModal.tsx │ │ │ │ ├── DeleteTrackModal.tsx │ │ │ │ ├── DynamicMixModal.tsx │ │ │ │ └── StaticMixModal.tsx │ │ │ ├── SongTable.css │ │ │ ├── SongTable.tsx │ │ │ └── StatusIcon.tsx │ │ └── Upload │ │ │ ├── CustomInput.tsx │ │ │ ├── CustomPreview.tsx │ │ │ ├── UploadModal.css │ │ │ ├── UploadModal.tsx │ │ │ ├── UploadModalForm.tsx │ │ │ ├── YouTubeForm.css │ │ │ ├── YouTubeForm.tsx │ │ │ └── YouTubeSearchResultList.tsx │ ├── favicon.ico │ ├── favicon.svg │ ├── index.tsx │ ├── models │ │ ├── DynamicMix.ts │ │ ├── MusicParts.ts │ │ ├── PartId.ts │ │ ├── Separator.ts │ │ ├── SongData.ts │ │ ├── StaticMix.ts │ │ ├── TaskStatus.ts │ │ ├── YouTubeLinkFetchStatus.ts │ │ ├── YouTubeSearchResponse.ts │ │ └── YouTubeVideo.ts │ ├── svg │ │ ├── cancel.svg │ │ ├── remove.svg │ │ └── restart.svg │ └── types │ │ └── index.d.ts ├── templates │ └── index.html ├── tsconfig.json ├── urls.py ├── views.py ├── webpack.dev.config.js └── webpack.prod.config.js ├── gpu.Dockerfile ├── gpu.cuda116.Dockerfile ├── manage.py ├── nginx ├── 15-disable-enable-ssl.sh ├── Dockerfile └── templates │ ├── default.conf.template │ └── default.ssl.conf.template ├── package.json ├── requirements-spleeter.txt ├── requirements.lock ├── requirements.txt └── screenshots ├── main.png ├── mixer.png └── upload.png /.dockerignore: -------------------------------------------------------------------------------- 1 | .github 2 | .git 3 | *Dockerfile* 4 | *docker-compose* 5 | web-entrypoint.sh 6 | 7 | docs 8 | env 9 | staticfiles 10 | screenshots 11 | 12 | frontend/assets/ 13 | frontend/node_modules 14 | frontend/src 15 | frontend/webpack-stats.json 16 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | webpack.*.config.js 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [JeffreyCA] 2 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "pip" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 20 13 | allow: 14 | - dependency-type: "all" 15 | ignore: 16 | - dependency-name: "*" 17 | update-types: ["version-update:semver-major"] 18 | - dependency-name: "celery" # due to conflict with 'click' package with spleeter 19 | - dependency-name: "numpy" # due to conflict with 'nnabla' 20 | - dependency-name: "nnabla" # due to conflict with 'numpy' 21 | - dependency-name: "nnabla-ext-*" # due to conflict with 'numpy' 22 | - dependency-name: "protobuf" # due to conflict with 'nnabla' 23 | - dependency-name: "tensorflow" # due to conflict with 'spleeter' 24 | update-types: ["version-update:semver-major", "version-update:semver-minor"] 25 | - dependency-name: "librosa" # Spleeter dependencies 26 | - dependency-name: "numba" # Spleeter dependencies 27 | - dependency-name: "llvmlite" # Spleeter dependencies 28 | - dependency-name: "httpx" # due to https://github.com/deezer/spleeter/pull/808 29 | 30 | - package-ecosystem: "npm" 31 | directory: "/frontend" 32 | schedule: 33 | interval: "daily" 34 | open-pull-requests-limit: 20 35 | ignore: 36 | - dependency-name: "*" 37 | update-types: ["version-update:semver-major"] 38 | - dependency-name: "tone" # https://github.com/Tonejs/Tone.js/pull/971 39 | -------------------------------------------------------------------------------- /.github/workflows/automerge.yml: -------------------------------------------------------------------------------- 1 | name: Dependabot auto-merge 2 | on: pull_request 3 | 4 | permissions: 5 | contents: write 6 | pull-requests: write 7 | 8 | jobs: 9 | dependabot: 10 | runs-on: ubuntu-latest 11 | if: github.actor == 'dependabot[bot]' 12 | steps: 13 | - name: Dependabot metadata 14 | id: metadata 15 | uses: dependabot/fetch-metadata@v1 16 | with: 17 | github-token: "${{ secrets.GITHUB_TOKEN }}" 18 | - name: Enable auto-merge for Dependabot PRs 19 | if: steps.metadata.outputs.update-type != 'version-update:semver-major' && (contains(steps.metadata.outputs.dependency-names, 'boto3') || contains(steps.metadata.outputs.dependency-names, 'google-api-python-client')) 20 | run: gh pr merge --auto --merge "$PR_URL" 21 | env: 22 | PR_URL: ${{github.event.pull_request.html_url}} 23 | GH_TOKEN: ${{secrets.GITHUB_TOKEN}} 24 | -------------------------------------------------------------------------------- /.github/workflows/master.yml: -------------------------------------------------------------------------------- 1 | name: Docker Compose push (master) 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | jobs: 10 | build-cpu: 11 | name: Build and publish CPU images 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - name: Docker login 16 | uses: Azure/docker-login@v1 17 | with: 18 | # Container registry username 19 | username: ${{ secrets.DOCKER_USERNAME }} 20 | # Container registry password 21 | password: ${{ secrets.DOCKER_TOKEN }} 22 | # Container registry server url 23 | login-server: https://index.docker.io/v1/ 24 | 25 | - name: Free disk space 26 | run: sudo rm -rf /usr/local/lib/android "$AGENT_TOOLSDIRECTORY" 27 | 28 | - name: Build images (latest) 29 | run: docker compose -f docker-compose.yml -f docker-compose.build.yml build 30 | 31 | - name: Push images to Docker Hub (latest) 32 | run: docker compose -f docker-compose.yml -f docker-compose.build.yml push 33 | env: 34 | DOCKER_CLIENT_TIMEOUT: 180 35 | COMPOSE_HTTP_TIMEOUT: 180 36 | 37 | build-gpu: 38 | name: Build and publish GPU images 39 | runs-on: ubuntu-latest 40 | steps: 41 | - uses: actions/checkout@v4 42 | - name: Docker login 43 | uses: Azure/docker-login@v1 44 | with: 45 | # Container registry username 46 | username: ${{ secrets.DOCKER_USERNAME }} 47 | # Container registry password 48 | password: ${{ secrets.DOCKER_TOKEN }} 49 | # Container registry server url 50 | login-server: https://index.docker.io/v1/ 51 | 52 | - name: Free disk space 53 | run: sudo rm -rf /usr/local/lib/android "$AGENT_TOOLSDIRECTORY" 54 | 55 | - name: Build GPU images (latest) 56 | run: docker compose -f docker-compose.gpu.yml -f docker-compose.build.gpu.yml build api 57 | 58 | - name: Push GPU images to Docker Hub (latest) 59 | run: docker compose -f docker-compose.gpu.yml -f docker-compose.build.gpu.yml push api 60 | env: 61 | DOCKER_CLIENT_TIMEOUT: 180 62 | COMPOSE_HTTP_TIMEOUT: 180 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Docker Compose push (release) 2 | 3 | on: 4 | release: 5 | types: [ created ] 6 | 7 | jobs: 8 | build-cpu: 9 | name: Build and publish CPU images 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Docker login 14 | uses: Azure/docker-login@v1 15 | with: 16 | # Container registry username 17 | username: ${{ secrets.DOCKER_USERNAME }} 18 | # Container registry password 19 | password: ${{ secrets.DOCKER_TOKEN }} 20 | # Container registry server url 21 | login-server: https://index.docker.io/v1/ 22 | 23 | - name: Free disk space 24 | run: sudo rm -rf /usr/local/lib/android "$AGENT_TOOLSDIRECTORY" 25 | 26 | - name: Build images (release) 27 | run: docker compose -f docker-compose.yml -f docker-compose.build.yml build 28 | env: 29 | TAG: ${{ github.event.release.tag_name }} 30 | 31 | - name: Push images to Docker Hub (release) 32 | run: docker compose -f docker-compose.yml -f docker-compose.build.yml push 33 | env: 34 | DOCKER_CLIENT_TIMEOUT: 180 35 | COMPOSE_HTTP_TIMEOUT: 180 36 | TAG: ${{ github.event.release.tag_name }} 37 | 38 | build-gpu: 39 | name: Build and publish GPU images 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - name: Docker login 44 | uses: Azure/docker-login@v1 45 | with: 46 | # Container registry username 47 | username: ${{ secrets.DOCKER_USERNAME }} 48 | # Container registry password 49 | password: ${{ secrets.DOCKER_TOKEN }} 50 | # Container registry server url 51 | login-server: https://index.docker.io/v1/ 52 | 53 | - name: Free disk space 54 | run: sudo rm -rf /usr/local/lib/android "$AGENT_TOOLSDIRECTORY" 55 | 56 | - name: Build GPU images (release) 57 | run: docker compose -f docker-compose.gpu.yml -f docker-compose.build.gpu.yml build api 58 | env: 59 | TAG: ${{ github.event.release.tag_name }} 60 | 61 | - name: Push GPU images to Docker Hub (release) 62 | run: docker compose -f docker-compose.gpu.yml -f docker-compose.build.gpu.yml push api 63 | env: 64 | DOCKER_CLIENT_TIMEOUT: 180 65 | COMPOSE_HTTP_TIMEOUT: 180 66 | TAG: ${{ github.event.release.tag_name }} 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | 3 | out/ 4 | staticfiles/ 5 | 6 | # Models 7 | pretrained_models/ 8 | 9 | # Upload/output directories 10 | output/ 11 | uploads/ 12 | 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # Flask stuff: 19 | instance/ 20 | .webassets-cache 21 | 22 | # pyenv 23 | .python-version 24 | 25 | # Celery stuff 26 | celerybeat-schedule 27 | celerybeat.pid 28 | celery.state.db 29 | *.log 30 | *.pid 31 | 32 | # Environments 33 | .env 34 | .venv 35 | env/ 36 | venv/ 37 | ENV/ 38 | env.bak/ 39 | venv.bak/ 40 | 41 | *.sqlite3 42 | .coverage 43 | media/ 44 | 45 | certbot/ 46 | -------------------------------------------------------------------------------- /.style.yapf: -------------------------------------------------------------------------------- 1 | [style] 2 | split_before_logical_operator = true 3 | column_limit = 140 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.9-bullseye 2 | 3 | ENV PYTHONDONTWRITEBYTECODE 1 4 | ENV PYTHONUNBUFFERED 1 5 | 6 | RUN mkdir -p /webapp/media /webapp/staticfiles 7 | WORKDIR /webapp 8 | 9 | # Install all dependencies 10 | RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg libasound2-dev libsndfile-dev libhdf5-dev 11 | 12 | COPY requirements.txt requirements-spleeter.txt /webapp/ 13 | RUN pip install --upgrade pip wheel && pip install -r requirements.txt 14 | RUN pip install -r requirements-spleeter.txt --no-dependencies 15 | 16 | COPY . . 17 | 18 | # Copy over entrypoint script 19 | COPY api-entrypoint.sh /usr/local/bin/ 20 | RUN chmod +x /usr/local/bin/api-entrypoint.sh && ln -s /usr/local/bin/api-entrypoint.sh / 21 | ENTRYPOINT ["api-entrypoint.sh"] 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Jeffrey Chen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /api-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Collect static files 4 | if [[ -z "${DJANGO_DEVELOPMENT}" ]]; then 5 | echo "Waiting for asset creation" 6 | while [ ! -d /webapp/frontend/assets/dist ]; do 7 | sleep 1 8 | done 9 | echo "Collect static files" 10 | python3.9 manage.py collectstatic --noinput 11 | fi 12 | 13 | echo "Applying migrations" 14 | python3.9 manage.py makemigrations api 15 | python3.9 manage.py migrate 16 | 17 | echo "Starting server" 18 | if [[ -z "${DJANGO_DEVELOPMENT}" ]]; then 19 | gunicorn -b $API_HOST:8000 django_react.wsgi 20 | else 21 | python3.9 manage.py runserver $API_HOST:8000 22 | fi 23 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- 1 | # This will make sure the app is always imported when 2 | # Django starts so that shared_task will use this app. 3 | from .celery import app as celery_app 4 | 5 | __all__ = ('celery_app', ) 6 | -------------------------------------------------------------------------------- /api/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import * 3 | 4 | # Register your models here. 5 | admin.site.register(SourceFile) 6 | admin.site.register(SourceTrack) 7 | admin.site.register(StaticMix) 8 | admin.site.register(DynamicMix) 9 | admin.site.register(YTAudioDownloadTask) 10 | -------------------------------------------------------------------------------- /api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class ApiConfig(AppConfig): 4 | name = 'api' 5 | 6 | def ready(self): 7 | import api.signals 8 | -------------------------------------------------------------------------------- /api/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | # Set the default Django settings module for the 'celery' program. 6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_react.settings') 7 | 8 | app = Celery('api') 9 | 10 | # Using a string here means the worker doesn't have to serialize 11 | # the configuration object to child processes. 12 | # - namespace='CELERY' means all celery-related configuration keys 13 | # should have a `CELERY_` prefix. 14 | app.config_from_object('django.conf:settings', namespace='CELERY') 15 | 16 | # Load task modules from all registered Django app configs. 17 | app.autodiscover_tasks() 18 | 19 | @app.task(bind=True) 20 | def debug_task(self): 21 | print(f'Request: {self.request!r}') 22 | -------------------------------------------------------------------------------- /api/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.8 on 2020-07-25 03:01 2 | 3 | import api.models 4 | import api.validators 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | 12 | initial = True 13 | 14 | dependencies = [ 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='SourceFile', 20 | fields=[ 21 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 22 | ('file', models.FileField(blank=True, max_length=255, null=True, upload_to=api.models.source_file_path, validators=[api.validators.is_valid_size, api.validators.is_valid_audio_file])), 23 | ('is_youtube', models.BooleanField(default=False)), 24 | ('youtube_link', models.URLField(blank=True, null=True, unique=True, validators=[api.validators.is_valid_youtube])), 25 | ], 26 | ), 27 | migrations.CreateModel( 28 | name='SourceTrack', 29 | fields=[ 30 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 31 | ('artist', models.CharField(max_length=200)), 32 | ('title', models.CharField(max_length=200)), 33 | ('date_created', models.DateTimeField(auto_now_add=True)), 34 | ('source_file', models.OneToOneField(on_delete=django.db.models.deletion.DO_NOTHING, to='api.SourceFile')), 35 | ], 36 | ), 37 | migrations.CreateModel( 38 | name='YTAudioDownloadTask', 39 | fields=[ 40 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 41 | ('status', models.IntegerField(choices=[(0, 'Queued'), (1, 'In Progress'), (2, 'Done'), (-1, 'Error')], default=0)), 42 | ('error', models.TextField(blank=True)), 43 | ], 44 | ), 45 | migrations.AddField( 46 | model_name='sourcefile', 47 | name='youtube_fetch_task', 48 | field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.DO_NOTHING, to='api.YTAudioDownloadTask'), 49 | ), 50 | migrations.CreateModel( 51 | name='StaticMix', 52 | fields=[ 53 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 54 | ('vocals', models.BooleanField()), 55 | ('drums', models.BooleanField()), 56 | ('bass', models.BooleanField()), 57 | ('other', models.BooleanField()), 58 | ('status', models.IntegerField(choices=[(0, 'Queued'), (1, 'In Progress'), (2, 'Done'), (-1, 'Error')], default=0)), 59 | ('file', models.FileField(blank=True, max_length=255, upload_to=api.models.mix_track_path)), 60 | ('error', models.TextField(blank=True)), 61 | ('date_created', models.DateTimeField(auto_now_add=True)), 62 | ('source_track', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='static', to='api.SourceTrack')), 63 | ], 64 | options={ 65 | 'unique_together': {('source_track', 'vocals', 'drums', 'bass', 'other')}, 66 | }, 67 | ), 68 | migrations.CreateModel( 69 | name='DynamicMix', 70 | fields=[ 71 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 72 | ('vocals_file', models.FileField(blank=True, max_length=255, upload_to=api.models.mix_track_path)), 73 | ('other_file', models.FileField(blank=True, max_length=255, upload_to=api.models.mix_track_path)), 74 | ('bass_file', models.FileField(blank=True, max_length=255, upload_to=api.models.mix_track_path)), 75 | ('drums_file', models.FileField(blank=True, max_length=255, upload_to=api.models.mix_track_path)), 76 | ('status', models.IntegerField(choices=[(0, 'Queued'), (1, 'In Progress'), (2, 'Done'), (-1, 'Error')], default=0)), 77 | ('error', models.TextField(blank=True)), 78 | ('date_created', models.DateTimeField(auto_now_add=True)), 79 | ('source_track', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dynamic', to='api.SourceTrack')), 80 | ], 81 | options={ 82 | 'unique_together': {('source_track',)}, 83 | }, 84 | ), 85 | ] 86 | -------------------------------------------------------------------------------- /api/migrations/0002_auto_20201218_0343.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.2 on 2020-12-18 03:43 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0001_initial'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='dynamicmix', 15 | name='celery_id', 16 | field=models.UUIDField(blank=True, default=None, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='staticmix', 20 | name='celery_id', 21 | field=models.UUIDField(blank=True, default=None, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name='ytaudiodownloadtask', 25 | name='celery_id', 26 | field=models.UUIDField(blank=True, default=None, null=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /api/migrations/0003_auto_20201229_0321.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2020-12-29 03:21 2 | 3 | import django.core.validators 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('api', '0002_auto_20201218_0343'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='dynamicmix', 17 | name='random_shifts', 18 | field=models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MaxValueValidator(10)]), 19 | ), 20 | migrations.AddField( 21 | model_name='dynamicmix', 22 | name='separator', 23 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('demucs', (('demucs', 'Demucs'), ('demucs_extra', 'Demucs (extra)'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light (extra)'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet (extra)')))], default='spleeter', max_length=20), 24 | ), 25 | migrations.AddField( 26 | model_name='staticmix', 27 | name='random_shifts', 28 | field=models.PositiveSmallIntegerField(default=0, validators=[django.core.validators.MaxValueValidator(10)]), 29 | ), 30 | migrations.AddField( 31 | model_name='staticmix', 32 | name='separator', 33 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('demucs', (('demucs', 'Demucs'), ('demucs_extra', 'Demucs (extra)'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light (extra)'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet (extra)')))], default='spleeter', max_length=20), 34 | ), 35 | migrations.AlterField( 36 | model_name='dynamicmix', 37 | name='source_track', 38 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='dynamic', to='api.sourcetrack'), 39 | ), 40 | migrations.AlterUniqueTogether( 41 | name='dynamicmix', 42 | unique_together={('source_track', 'separator', 'random_shifts')}, 43 | ), 44 | migrations.AlterUniqueTogether( 45 | name='staticmix', 46 | unique_together={('source_track', 'separator', 'random_shifts', 'vocals', 'drums', 'bass', 'other')}, 47 | ), 48 | ] 49 | -------------------------------------------------------------------------------- /api/migrations/0004_auto_20210107_0131.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.4 on 2021-01-07 01:31 2 | 3 | from django.db import migrations, models 4 | import picklefield.fields 5 | 6 | def convert_random_shifts(apps, schema_editor): 7 | StaticMix = apps.get_model('api', 'StaticMix') 8 | for mix in StaticMix.objects.all(): 9 | mix.separator_args = {'random_shifts': mix.random_shifts} 10 | mix.save() 11 | 12 | DynamicMix = apps.get_model('api', 'DynamicMix') 13 | for mix in DynamicMix.objects.all(): 14 | mix.separator_args = {'random_shifts': mix.random_shifts} 15 | mix.save() 16 | 17 | class Migration(migrations.Migration): 18 | 19 | dependencies = [ 20 | ('api', '0003_auto_20201229_0321'), 21 | ] 22 | 23 | operations = [ 24 | migrations.AddField( 25 | model_name='dynamicmix', 26 | name='bitrate', 27 | field=models.IntegerField(choices=[(192, 'Mp3 192'), 28 | (256, 'Mp3 256'), 29 | (320, 'Mp3 320')], 30 | default=256), 31 | ), 32 | migrations.AddField( 33 | model_name='dynamicmix', 34 | name='separator_args', 35 | field=picklefield.fields.PickledObjectField(default=dict, 36 | editable=False), 37 | ), 38 | migrations.AddField( 39 | model_name='staticmix', 40 | name='bitrate', 41 | field=models.IntegerField(choices=[(192, 'Mp3 192'), 42 | (256, 'Mp3 256'), 43 | (320, 'Mp3 320')], 44 | default=256), 45 | ), 46 | migrations.AddField( 47 | model_name='staticmix', 48 | name='separator_args', 49 | field=picklefield.fields.PickledObjectField(default=dict, 50 | editable=False), 51 | ), 52 | migrations.AlterField( 53 | model_name='dynamicmix', 54 | name='separator', 55 | field=models.CharField(choices=[ 56 | ('spleeter', 'Spleeter'), 57 | ('demucs', 58 | (('demucs', 'Demucs'), ('demucs_extra', 'Demucs (extra)'), 59 | ('light', 'Demucs Light'), ('light_extra', 60 | 'Demucs Light (extra)'), 61 | ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet (extra)'))), 62 | ('xumx', 'X-UMX') 63 | ], 64 | default='spleeter', 65 | max_length=20), 66 | ), 67 | migrations.AlterField( 68 | model_name='staticmix', 69 | name='separator', 70 | field=models.CharField(choices=[ 71 | ('spleeter', 'Spleeter'), 72 | ('demucs', 73 | (('demucs', 'Demucs'), ('demucs_extra', 'Demucs (extra)'), 74 | ('light', 'Demucs Light'), ('light_extra', 75 | 'Demucs Light (extra)'), 76 | ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet (extra)'))), 77 | ('xumx', 'X-UMX') 78 | ], 79 | default='spleeter', 80 | max_length=20), 81 | ), 82 | migrations.AlterUniqueTogether( 83 | name='dynamicmix', 84 | unique_together={('source_track', 'separator', 'separator_args', 85 | 'bitrate')}, 86 | ), 87 | migrations.AlterUniqueTogether( 88 | name='staticmix', 89 | unique_together={('source_track', 'separator', 'separator_args', 90 | 'bitrate', 'vocals', 'drums', 'bass', 'other')}, 91 | ), 92 | migrations.RunPython(convert_random_shifts), 93 | migrations.RemoveField( 94 | model_name='dynamicmix', 95 | name='random_shifts', 96 | ), 97 | migrations.RemoveField( 98 | model_name='staticmix', 99 | name='random_shifts', 100 | ), 101 | ] 102 | -------------------------------------------------------------------------------- /api/migrations/0005_auto_20210526_0244.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.3 on 2021-05-26 02:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0004_auto_20210107_0131'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dynamicmix', 15 | name='separator', 16 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('demucs', (('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 17 | ), 18 | migrations.AlterField( 19 | model_name='staticmix', 20 | name='separator', 21 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('demucs', (('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0006_auto_20210614_1554.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.4 on 2021-06-14 15:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0005_auto_20210526_0244'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dynamicmix', 15 | name='separator', 16 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('demucs', (('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 17 | ), 18 | migrations.AlterField( 19 | model_name='staticmix', 20 | name='separator', 21 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('demucs', (('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0007_auto_20210621_0554.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.4 on 2021-06-21 05:54 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0006_auto_20210614_1554'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dynamicmix', 15 | name='separator', 16 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('d3net', 'D3Net'), ('demucs', (('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 17 | ), 18 | migrations.AlterField( 19 | model_name='staticmix', 20 | name='separator', 21 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('d3net', 'D3Net'), ('demucs', (('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0008_auto_20211217_0122.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-12-17 01:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0007_auto_20210621_0554'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dynamicmix', 15 | name='separator', 16 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('d3net', 'D3Net'), ('demucs', (('mdx', 'Demucs v3'), ('mdx_extra', 'Demucs v3 Extra'), ('mdx_q', 'Demucs v3 Quantized'), ('mdx_extra_q', 'Demucs v3 Extra Quantized'), ('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 17 | ), 18 | migrations.AlterField( 19 | model_name='staticmix', 20 | name='separator', 21 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('d3net', 'D3Net'), ('demucs', (('mdx', 'Demucs v3'), ('mdx_extra', 'Demucs v3 Extra'), ('mdx_q', 'Demucs v3 Quantized'), ('mdx_extra_q', 'Demucs v3 Extra Quantized'), ('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0009_auto_20220222_0232.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-02-22 02:32 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0008_auto_20211217_0122'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='dynamicmix', 15 | name='date_finished', 16 | field=models.DateTimeField(blank=True, default=None, null=True), 17 | ), 18 | migrations.AddField( 19 | model_name='staticmix', 20 | name='date_finished', 21 | field=models.DateTimeField(blank=True, default=None, null=True), 22 | ), 23 | migrations.AddField( 24 | model_name='ytaudiodownloadtask', 25 | name='date_finished', 26 | field=models.DateTimeField(blank=True, default=None, null=True), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /api/migrations/0010_alter_dynamicmix_separator_alter_staticmix_separator.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.3 on 2022-12-06 05:31 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0009_auto_20220222_0232'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dynamicmix', 15 | name='separator', 16 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('d3net', 'D3Net'), ('demucs', (('htdemucs', 'Demucs v4'), ('htdemucs_ft', 'Demucs v4 Fine-tuned'), ('hdemucs_mmi', 'Demucs v3 MMI'), ('mdx', 'Demucs v3'), ('mdx_extra', 'Demucs v3 Extra'), ('mdx_q', 'Demucs v3 Quantized'), ('mdx_extra_q', 'Demucs v3 Extra Quantized'), ('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 17 | ), 18 | migrations.AlterField( 19 | model_name='staticmix', 20 | name='separator', 21 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('d3net', 'D3Net'), ('demucs', (('htdemucs', 'Demucs v4'), ('htdemucs_ft', 'Demucs v4 Fine-tuned'), ('hdemucs_mmi', 'Demucs v3 MMI'), ('mdx', 'Demucs v3'), ('mdx_extra', 'Demucs v3 Extra'), ('mdx_q', 'Demucs v3 Quantized'), ('mdx_extra_q', 'Demucs v3 Extra Quantized'), ('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0011_alter_dynamicmix_bitrate_alter_staticmix_bitrate.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.4 on 2022-12-26 01:50 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('api', '0010_alter_dynamicmix_separator_alter_staticmix_separator'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='dynamicmix', 15 | name='bitrate', 16 | field=models.IntegerField(choices=[(0, 'WAV'), (1, 'FLAC'), (192, '192 kbps'), (256, '256 kbps'), (320, '320 kbps')], default=256), 17 | ), 18 | migrations.AlterField( 19 | model_name='staticmix', 20 | name='bitrate', 21 | field=models.IntegerField(choices=[(0, 'WAV'), (1, 'FLAC'), (192, '192 kbps'), (256, '256 kbps'), (320, '320 kbps')], default=256), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /api/migrations/0012_alter_staticmix_unique_together_and_more.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.5 on 2023-01-12 06:15 2 | 3 | import api.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('api', '0011_alter_dynamicmix_bitrate_alter_staticmix_bitrate'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterUniqueTogether( 15 | name='staticmix', 16 | unique_together=set(), 17 | ), 18 | migrations.AddField( 19 | model_name='dynamicmix', 20 | name='piano_file', 21 | field=models.FileField(blank=True, default=None, max_length=255, null=True, upload_to=api.models.mix_track_path), 22 | ), 23 | migrations.AddField( 24 | model_name='staticmix', 25 | name='piano', 26 | field=models.BooleanField(default=None, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='dynamicmix', 30 | name='separator', 31 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('spleeter_5stems', 'Spleeter with Piano'), ('d3net', 'D3Net'), ('demucs', (('htdemucs', 'Demucs v4'), ('htdemucs_ft', 'Demucs v4 Fine-tuned'), ('hdemucs_mmi', 'Demucs v3 MMI'), ('mdx', 'Demucs v3'), ('mdx_extra', 'Demucs v3 Extra'), ('mdx_q', 'Demucs v3 Quantized'), ('mdx_extra_q', 'Demucs v3 Extra Quantized'), ('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 32 | ), 33 | migrations.AlterField( 34 | model_name='staticmix', 35 | name='separator', 36 | field=models.CharField(choices=[('spleeter', 'Spleeter'), ('spleeter_5stems', 'Spleeter with Piano'), ('d3net', 'D3Net'), ('demucs', (('htdemucs', 'Demucs v4'), ('htdemucs_ft', 'Demucs v4 Fine-tuned'), ('hdemucs_mmi', 'Demucs v3 MMI'), ('mdx', 'Demucs v3'), ('mdx_extra', 'Demucs v3 Extra'), ('mdx_q', 'Demucs v3 Quantized'), ('mdx_extra_q', 'Demucs v3 Extra Quantized'), ('demucs', 'Demucs'), ('demucs48_hq', 'Demucs HQ'), ('demucs_extra', 'Demucs Extra'), ('demucs_quantized', 'Demucs Quantized'), ('tasnet', 'Tasnet'), ('tasnet_extra', 'Tasnet Extra'), ('light', 'Demucs Light'), ('light_extra', 'Demucs Light Extra'))), ('xumx', 'X-UMX')], default='spleeter', max_length=20), 37 | ), 38 | migrations.AlterUniqueTogether( 39 | name='staticmix', 40 | unique_together={('source_track', 'separator', 'separator_args', 'bitrate', 'vocals', 'drums', 'bass', 'other', 'piano')}, 41 | ), 42 | ] 43 | -------------------------------------------------------------------------------- /api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeffreyCA/spleeter-web/945b3c48eec5d55998497dc817641a1a1e9ad937/api/migrations/__init__.py -------------------------------------------------------------------------------- /api/separators/d3net_openvino.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | openvino_enabled = False 4 | try: 5 | from openvino.inference_engine import IECore 6 | ie = IECore() 7 | openvino_enabled = True 8 | except: 9 | pass 10 | 11 | """ 12 | Reimplemented from https://github.com/sony/ai-research-code/blob/master/d3net/music-source-separation/model_openvino.py which is under copyright by Sony Corporation under the terms of the Apache license. 13 | """ 14 | 15 | class D3NetOpenVinoWrapper(object): 16 | def __init__(self, model_dir_path, source, nthreads): 17 | if not openvino_enabled: 18 | raise ValueError( 19 | 'Failed to import openvino! Please make sure you have installed openvino.' 20 | ) 21 | weight = model_dir_path / f'{source}.onnx' 22 | if not os.path.exists(weight): 23 | raise ValueError( 24 | '{} does not exist. Please download weight file beforehand. You can see README.md for the detail.' 25 | .format(weight)) 26 | self.net = ie.read_network(model=weight) 27 | self.input_blob = next(iter(self.net.input_info)) 28 | self.out_blob = next(iter(self.net.outputs)) 29 | self.exec_net = ie.load_network( 30 | network=self.net, 31 | device_name='CPU', 32 | config={'CPU_THREADS_NUM': str(nthreads)}) 33 | 34 | def run(self, input_var): 35 | output = self.exec_net.infer(inputs={self.input_blob: input_var}) 36 | return output[list(output.keys())[0]] 37 | -------------------------------------------------------------------------------- /api/separators/spleeter_separator.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import numpy as np 4 | from spleeter import * 5 | from spleeter.audio.adapter import AudioAdapter 6 | from spleeter.separator import Separator 7 | from spleeter.utils import * 8 | from spleeter.audio import STFTBackend 9 | from api.models import OutputFormat 10 | 11 | from api.util import output_format_to_ext, is_output_format_lossy 12 | 13 | """ 14 | This module defines a wrapper interface over the Spleeter API. 15 | """ 16 | 17 | class SpleeterSeparator: 18 | """Performs source separation using Spleeter API.""" 19 | def __init__(self, cpu_separation: bool, output_format=OutputFormat.MP3_256.value, with_piano: bool = False): 20 | """Default constructor. 21 | :param config: Separator config, defaults to None 22 | """ 23 | self.audio_bitrate = f'{output_format}k' if is_output_format_lossy( 24 | output_format) else None 25 | self.audio_format = output_format_to_ext(output_format) 26 | self.sample_rate = 44100 27 | self.spleeter_stem = 'config/5stems-16kHz.json' if with_piano else 'config/4stems-16kHz.json' 28 | self.separator = Separator(self.spleeter_stem, 29 | stft_backend=STFTBackend.LIBROSA if cpu_separation else STFTBackend.TENSORFLOW, 30 | multiprocess=False) 31 | self.audio_adapter = AudioAdapter.default() 32 | 33 | def check_and_remove_empty_model_dirs(self): 34 | model_paths = [ 35 | Path('pretrained_models', '4stems'), 36 | Path('pretrained_models', '5stems') 37 | ] 38 | for model_path in model_paths: 39 | if model_path.exists() and not any(model_path.iterdir()): 40 | model_path.rmdir() 41 | 42 | def create_static_mix(self, parts, input_path, output_path): 43 | """Creates a static mix by performing source separation and adding the 44 | parts to be kept into a single track. 45 | 46 | :param parts: List of parts to keep 47 | :param input_path: Path to source file 48 | :param output_path: Path to output file 49 | :raises e: FFMPEG error 50 | """ 51 | waveform, _ = self.audio_adapter.load(input_path, 52 | sample_rate=self.sample_rate) 53 | self.check_and_remove_empty_model_dirs() 54 | prediction = self.separator.separate(waveform, '') 55 | out = np.zeros_like(prediction['vocals']) 56 | 57 | # Add up parts that were requested 58 | for key in prediction: 59 | if parts[key]: 60 | out += prediction[key] 61 | 62 | self.audio_adapter.save(output_path, out, self.sample_rate, 63 | self.audio_format, self.audio_bitrate) 64 | 65 | def separate_into_parts(self, input_path, output_path): 66 | """Creates a dynamic mix 67 | 68 | :param input_path: Input path 69 | :param output_path: Output path 70 | """ 71 | self.check_and_remove_empty_model_dirs() 72 | self.separator.separate_to_file(input_path, 73 | output_path, 74 | self.audio_adapter, 75 | codec=self.audio_format, 76 | duration=None, 77 | bitrate=self.audio_bitrate, 78 | filename_format='{instrument}.{codec}', 79 | synchronous=False) 80 | self.separator.join(600) 81 | -------------------------------------------------------------------------------- /api/separators/util.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from tqdm import tqdm 3 | import hashlib 4 | 5 | SHA1_CHECK_BUF_SIZE = 65536 6 | 7 | def download_file(url, target): 8 | def _download(): 9 | response = requests.get(url, stream=True) 10 | content_type = response.headers.get('Content-Type', None) 11 | if content_type is None or content_type == 'text/html': 12 | raise ValueError(f'Invalid model URL: {url}') 13 | 14 | total_length = int(response.headers.get('content-length', 0)) 15 | 16 | with tqdm(total=total_length, ncols=120, unit="B", 17 | unit_scale=True) as bar: 18 | with open(target, "wb") as output: 19 | for data in response.iter_content(chunk_size=4096): 20 | output.write(data) 21 | bar.update(len(data)) 22 | 23 | try: 24 | _download() 25 | except: 26 | if target.exists(): 27 | target.unlink() 28 | raise 29 | 30 | def download_and_verify(model_url, expected_sha1, model_dir, model_file_path): 31 | sha1 = None 32 | if model_file_path.is_file(): 33 | sha1 = hashlib.sha1() 34 | with open(str(model_file_path), 'rb') as f: 35 | while True: 36 | data = f.read(SHA1_CHECK_BUF_SIZE) 37 | if not data: 38 | break 39 | sha1.update(data) 40 | 41 | if not sha1 or sha1.hexdigest() != expected_sha1: 42 | model_dir.mkdir(exist_ok=True, parents=True) 43 | print("Downloading pre-trained model, this could take a while...") 44 | download_file(model_url, model_file_path) 45 | -------------------------------------------------------------------------------- /api/separators/x_umx_separator.py: -------------------------------------------------------------------------------- 1 | from argparse import ArgumentParser 2 | import os 3 | import warnings 4 | from pathlib import Path 5 | 6 | import nnabla as nn 7 | import numpy as np 8 | from api.models import OutputFormat 9 | from api.separators.util import download_and_verify 10 | from billiard.pool import Pool 11 | from nnabla.ext_utils import get_extension_context 12 | from spleeter.audio.adapter import AudioAdapter 13 | from tqdm import trange 14 | from xumx.test import separate_args_dict 15 | 16 | from api.util import output_format_to_ext, is_output_format_lossy 17 | 18 | 19 | MODEL_URL = 'https://nnabla.org/pretrained-models/ai-research-code/x-umx/x-umx.h5' 20 | MODEL_SHA1 = '6414e08527d37bd1d08130c4b87255830af819bf' 21 | 22 | """ 23 | This module reimplements part of X-UMX's source separation code from https://github.com/sony/ai-research-code/blob/master/x-umx/test.py which is under copyright by Sony Corporation under the terms of the MIT license. 24 | """ 25 | 26 | class XUMXSeparator: 27 | """Performs source separation using X-UMX API.""" 28 | def __init__(self, 29 | cpu_separation: bool, 30 | output_format=OutputFormat.MP3_256.value, 31 | softmask=False, 32 | alpha=1.0, 33 | iterations=1): 34 | """Default constructor. 35 | :param config: Separator config, defaults to None 36 | """ 37 | self.model_file = 'x-umx.h5' 38 | self.model_dir = Path('pretrained_models') 39 | self.model_file_path = self.model_dir / self.model_file 40 | self.context = 'cpu' if cpu_separation else 'cudnn' 41 | self.softmask = softmask 42 | self.alpha = alpha 43 | self.iterations = iterations 44 | self.audio_bitrate = f'{output_format}k' if is_output_format_lossy(output_format) else None 45 | self.audio_format = output_format_to_ext(output_format) 46 | self.sample_rate = 44100 47 | self.residual_model = False 48 | self.audio_adapter = AudioAdapter.default() 49 | self.chunk_duration = 30 50 | 51 | def get_estimates(self, input_path: str): 52 | ctx = get_extension_context(self.context) 53 | nn.set_default_context(ctx) 54 | nn.set_auto_forward(True) 55 | 56 | audio, _ = self.audio_adapter.load(input_path, 57 | sample_rate=self.sample_rate) 58 | 59 | if audio.shape[1] > 2: 60 | warnings.warn('Channel count > 2! ' 61 | 'Only the first two channels will be processed!') 62 | audio = audio[:, :2] 63 | 64 | if audio.shape[1] == 1: 65 | print('received mono file, so duplicate channels') 66 | audio = np.repeat(audio, 2, axis=1) 67 | 68 | # Split and separate sources using moving window protocol for each chunk of audio 69 | # chunk duration must be lower for machines with low memory 70 | chunk_size = self.sample_rate * self.chunk_duration 71 | if (audio.shape[0] % chunk_size) == 0: 72 | nchunks = (audio.shape[0] // chunk_size) 73 | else: 74 | nchunks = (audio.shape[0] // chunk_size) + 1 75 | 76 | estimates = {} 77 | 78 | separate_args = { 79 | 'model': str(self.model_file_path), 80 | 'umx_infer': False, 81 | 'targets': ['bass', 'drums', 'vocals', 'other'], 82 | 'softmask': self.softmask, 83 | 'alpha': self.alpha, 84 | 'residual_model': self.residual_model, 85 | 'niter': self.iterations 86 | } 87 | 88 | print('Separating...') 89 | for chunk_idx in trange(nchunks): 90 | cur_chunk = audio[chunk_idx * 91 | chunk_size:min((chunk_idx + 1) * 92 | chunk_size, audio.shape[0]), :] 93 | cur_estimates = separate_args_dict(cur_chunk, separate_args) 94 | if any(estimates) is False: 95 | estimates = cur_estimates 96 | else: 97 | for key in cur_estimates: 98 | estimates[key] = np.concatenate( 99 | (estimates[key], cur_estimates[key]), axis=0) 100 | return estimates 101 | 102 | def create_static_mix(self, parts, input_path: str, output_path: Path): 103 | download_and_verify(MODEL_URL, MODEL_SHA1, self.model_dir, 104 | self.model_file_path) 105 | estimates = self.get_estimates(input_path) 106 | 107 | final_source = None 108 | 109 | for name, source in estimates.items(): 110 | if not parts[name]: 111 | continue 112 | final_source = source if final_source is None else final_source + source 113 | 114 | print(f'Exporting to {output_path}...') 115 | self.audio_adapter.save(output_path, final_source, self.sample_rate, 116 | self.audio_format, self.audio_bitrate) 117 | 118 | def separate_into_parts(self, input_path: str, output_path: Path): 119 | download_and_verify(MODEL_URL, MODEL_SHA1, self.model_dir, 120 | self.model_file_path) 121 | estimates = self.get_estimates(input_path) 122 | 123 | # Export all sources in parallel 124 | pool = Pool() 125 | tasks = [] 126 | output_path = Path(output_path) 127 | 128 | for name, estimate in estimates.items(): 129 | filename = f'{name}.{self.audio_format}' 130 | print(f'Exporting {filename}...') 131 | task = pool.apply_async( 132 | self.audio_adapter.save, 133 | (output_path / filename, estimate, self.sample_rate, 134 | self.audio_format, self.audio_bitrate)) 135 | tasks.append(task) 136 | 137 | pool.close() 138 | pool.join() 139 | -------------------------------------------------------------------------------- /api/setup.cfg: -------------------------------------------------------------------------------- 1 | [yapf] 2 | based_on_style = pep8 3 | spaces_before_comment = 4 4 | split_before_logical_operator = true 5 | blank_lines_around_top_level_definition = 1 6 | -------------------------------------------------------------------------------- /api/signals.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shutil 3 | 4 | from django.conf import settings 5 | from django.db.models.signals import post_delete, pre_delete 6 | from django.dispatch import receiver 7 | 8 | from .models import DynamicMix, SourceFile, SourceTrack, StaticMix 9 | 10 | """ 11 | This module defines pre- and post-delete signals to ensure files are deleted when a model is deleted from the DB. 12 | """ 13 | 14 | @receiver(pre_delete, 15 | sender=SourceFile, 16 | dispatch_uid='delete_source_file_signal') 17 | def delete_source_file(sender, instance, using, **kwargs): 18 | """Pre-delete signal to delete source file on disk before deleting instance.""" 19 | if instance.file: 20 | instance.file.delete() 21 | 22 | # Delete directory 23 | if str(instance.id): 24 | directory = os.path.join(settings.MEDIA_ROOT, settings.UPLOAD_DIR, 25 | str(instance.id)) 26 | shutil.rmtree(directory, ignore_errors=True) 27 | print('Removed directory: ', directory) 28 | 29 | if instance.youtube_fetch_task: 30 | instance.youtube_fetch_task.delete() 31 | 32 | @receiver(post_delete, 33 | sender=SourceTrack, 34 | dispatch_uid='delete_source_track_signal') 35 | def delete_source_track(sender, instance, using, **kwargs): 36 | """Post-delete signal to source track file on disk before deleting instance.""" 37 | if instance.source_file: 38 | # This will call delete_source_file above 39 | instance.source_file.delete() 40 | 41 | @receiver(pre_delete, 42 | sender=StaticMix, 43 | dispatch_uid='delete_static_mix_signal') 44 | def delete_static_mix(sender, instance, using, **kwargs): 45 | """ 46 | Pre-delete signal to static mix file on disk before deleting instance. 47 | 48 | Cannot be post-delete or else submitting a separation task with 'overwrite' flag does 49 | not work. 50 | """ 51 | if instance.file: 52 | instance.file.delete() 53 | 54 | # Delete directory 55 | if str(instance.id): 56 | directory = os.path.join(settings.MEDIA_ROOT, settings.SEPARATE_DIR, 57 | str(instance.id)) 58 | shutil.rmtree(directory, ignore_errors=True) 59 | print('Removed directory: ', directory) 60 | 61 | @receiver(pre_delete, 62 | sender=DynamicMix, 63 | dispatch_uid='delete_dynamic_mix_signal') 64 | def delete_dynamic_mix(sender, instance, using, **kwargs): 65 | if instance.vocals_file: 66 | instance.vocals_file.delete() 67 | if instance.other_file: 68 | instance.other_file.delete() 69 | if instance.piano_file: 70 | instance.piano_file.delete() 71 | if instance.bass_file: 72 | instance.bass_file.delete() 73 | if instance.drums_file: 74 | instance.drums_file.delete() 75 | 76 | # Delete directory 77 | if str(instance.id): 78 | directory = os.path.join(settings.MEDIA_ROOT, settings.SEPARATE_DIR, 79 | str(instance.id)) 80 | shutil.rmtree(directory, ignore_errors=True) 81 | print('Removed directory: ', directory) 82 | -------------------------------------------------------------------------------- /api/storage.py: -------------------------------------------------------------------------------- 1 | from django.core.files.storage import \ 2 | FileSystemStorage as BaseFileSystemStorage 3 | from django.utils.deconstruct import deconstructible 4 | from storages.backends.azure_storage import AzureStorage as BaseAzureStorage 5 | from storages.backends.s3boto3 import S3Boto3Storage as BaseS3Boto3Storage 6 | 7 | from .util import get_valid_filename 8 | 9 | """ 10 | Simple wrappers of the base storage backends except that characters like spaces, commas, brackets 11 | are allowed in the filename. 12 | """ 13 | 14 | @deconstructible 15 | class FileSystemStorage(BaseFileSystemStorage): 16 | def get_valid_name(self, name): 17 | return get_valid_filename(name) 18 | 19 | @deconstructible 20 | class S3Boto3Storage(BaseS3Boto3Storage): 21 | def get_valid_name(self, name): 22 | return get_valid_filename(name) 23 | 24 | @deconstructible 25 | class AzureStorage(BaseAzureStorage): 26 | def get_valid_name(self, name): 27 | return get_valid_filename(name) 28 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from django.conf import settings 3 | from django.conf.urls.static import static 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | path('api/search/', views.YouTubeSearchView.as_view()), 9 | path('api/source-file/all/', views.SourceFileListView.as_view()), 10 | path( 11 | 'api/source-file/file/', 12 | views.SourceFileView.as_view({ 13 | 'post': 'create', 14 | 'delete': 'perform_destroy' 15 | })), 16 | path('api/source-file/youtube/', views.YTLinkInfoView.as_view()), 17 | path('api/source-track/', views.SourceTrackListView.as_view()), 18 | path('api/source-track//', 19 | views.SourceTrackRetrieveUpdateDestroyView.as_view()), 20 | path('api/source-track/file/', views.FileSourceTrackView.as_view()), 21 | path('api/source-track/youtube/', views.YTSourceTrackView.as_view()), 22 | path('api/mix/static/', views.StaticMixCreateView.as_view()), 23 | path('api/mix/static//', 24 | views.StaticMixRetrieveDestroyView.as_view()), 25 | path('api/mix/dynamic/', views.DynamicMixCreateView.as_view()), 26 | path('api/mix/dynamic//', 27 | views.DynamicMixRetrieveDestroyView.as_view()), 28 | path('api/task/', views.YTAudioDownloadTaskListView.as_view()), 29 | path('api/task//', 30 | views.YTAudioDownloadTaskRetrieveView.as_view()) 31 | ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) 32 | -------------------------------------------------------------------------------- /api/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from api.models import OutputFormat 4 | 5 | ALL_PARTS = ['vocals', 'other', 'bass', 'drums'] 6 | ALL_PARTS_5 = ['vocals', 'other', 'piano', 'bass', 'drums'] 7 | 8 | def get_valid_filename(s): 9 | """ 10 | Return the given string converted to a string that can be used for a clean 11 | filename. Remove leading and trailing spaces; remove anything that is not an 12 | alphanumeric, dash, whitespace, comma, bracket, underscore, or dot. 13 | >>> get_valid_filename("john's portrait in 2004.jpg") 14 | 'johns_portrait_in_2004.jpg' 15 | """ 16 | s = str(s).strip() 17 | return re.sub(r'(?u)[^-\w\s.,[\]()]', '', s) 18 | 19 | def is_output_format_lossy(output_format: int): 20 | """Return whether OutputFormat enum is a lossy format.""" 21 | return output_format != OutputFormat.FLAC.value and output_format != OutputFormat.WAV.value 22 | 23 | def output_format_to_ext(output_format: int): 24 | """Resolve OutputFormat enum to a file extension.""" 25 | if output_format == OutputFormat.FLAC.value: 26 | return 'flac' 27 | elif output_format == OutputFormat.WAV.value: 28 | return 'wav' 29 | else: 30 | return 'mp3' 31 | -------------------------------------------------------------------------------- /api/validators.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import magic 4 | from django.conf import settings 5 | from django.core.exceptions import ValidationError 6 | from yt_dlp.utils import DownloadError 7 | 8 | from .youtubedl import get_meta_info 9 | 10 | """ 11 | This module contains validation functions. 12 | """ 13 | 14 | def is_valid_size(value): 15 | """ 16 | Validate file size is within upload file size limit. 17 | 18 | :param value: File size in bytes 19 | """ 20 | if value.size > settings.UPLOAD_FILE_SIZE_LIMIT: 21 | raise ValidationError('File too large.') 22 | 23 | def is_valid_audio_file(file): 24 | """ 25 | Validate audio file has a valid MIME type and file extension. 26 | 27 | :param file: Audio file 28 | """ 29 | # Only read initial bytes (usually enough to deduce file type) 30 | first_bytes = file.read(1024) 31 | file_mime_type = magic.from_buffer(first_bytes, mime=True) 32 | if file_mime_type == 'application/octet-stream': 33 | file_type = magic.from_buffer(first_bytes) 34 | if 'Audio file' not in file_type: 35 | raise ValidationError('File type not allowed.') 36 | elif file_mime_type not in settings.VALID_MIME_TYPES: 37 | raise ValidationError( 38 | f'MIME type {file_mime_type} not allowed.' 39 | ) 40 | 41 | ext = os.path.splitext(file.name)[1] 42 | if ext.lower() not in settings.VALID_FILE_EXT: 43 | raise ValidationError('File extension not allowed.') 44 | 45 | def is_valid_youtube(link): 46 | """ 47 | Validate YouTube link is a valid one and also within the duration limit. 48 | 49 | :param link: YouTube link to validate 50 | """ 51 | try: 52 | info = get_meta_info(link) 53 | if info['duration'] > settings.YOUTUBE_LENGTH_LIMIT: 54 | raise ValidationError('Video length too long.') 55 | except DownloadError: 56 | raise ValidationError('Invalid YouTube link.') 57 | -------------------------------------------------------------------------------- /api/youtube_search.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import googleapiclient.discovery 4 | import googleapiclient.errors 5 | from django.conf import settings 6 | from youtube_title_parse import get_artist_title 7 | 8 | logging.getLogger('googleapiclient.discovery_cache').setLevel(logging.ERROR) 9 | 10 | """ 11 | This module handles YouTube search API functionality. 12 | """ 13 | 14 | class YouTubeSearchError(Exception): 15 | pass 16 | 17 | def perform_search(query: str, page_token=None): 18 | """ 19 | Executes YouTube search request using YouTube Data API v3 and returns 20 | simplified list of video results. 21 | 22 | :param query: Query string 23 | :param page_token: Page token 24 | """ 25 | api_service_name = "youtube" 26 | api_version = "v3" 27 | 28 | if not settings.YOUTUBE_API_KEY: 29 | raise YouTubeSearchError('Missing YouTube Data API key. Please set the YOUTUBE_API_KEY env variable or update settings.py.') 30 | 31 | youtube = googleapiclient.discovery.build( 32 | api_service_name, 33 | api_version, 34 | developerKey=settings.YOUTUBE_API_KEY, 35 | cache_discovery=False) 36 | 37 | # Execute search query 38 | search_request = youtube.search().list(part="snippet", 39 | maxResults=25, 40 | q=query, 41 | pageToken=page_token) 42 | search_result = search_request.execute() 43 | search_items = search_result['items'] 44 | 45 | # Construct list of eligible video IDs 46 | ids = [ 47 | item['id']['videoId'] for item in search_items 48 | if item['id']['kind'] == 'youtube#video' 49 | and item['snippet']['liveBroadcastContent'] == 'none' 50 | ] 51 | # Make request to videos() in order to retrieve the durations 52 | duration_request = youtube.videos().list(part='contentDetails', id=','.join(ids)) 53 | duration_result = duration_request.execute() 54 | duration_items = duration_result['items'] 55 | duration_dict = { 56 | item['id']: item['contentDetails']['duration'] 57 | for item in duration_items 58 | } 59 | 60 | # Merge results into single, simplified list 61 | videos = [] 62 | for item in search_items: 63 | if item['id']['kind'] == 'youtube#video' and item['snippet']['liveBroadcastContent'] == 'none' and item['id']['videoId'] in duration_dict: 64 | parsed_artist = None 65 | parsed_title = None 66 | result = get_artist_title(item['snippet']['title']) 67 | 68 | if result: 69 | parsed_artist, parsed_title = result 70 | else: 71 | parsed_artist = item['snippet']['channelTitle'] 72 | parsed_title = item['snippet']['title'] 73 | 74 | videos.append( 75 | { 76 | 'id': item['id']['videoId'], 77 | 'title': item['snippet']['title'], 78 | 'parsed_artist': parsed_artist, 79 | 'parsed_title': parsed_title, 80 | 'channel': item['snippet']['channelTitle'], 81 | 'thumbnail': item['snippet']['thumbnails']['default']['url'], 82 | 'duration': duration_dict[item['id']['videoId']] 83 | } 84 | ) 85 | 86 | next_page_token = search_result['nextPageToken'] if 'nextPageToken' in search_result else None 87 | # Return next page token and video result 88 | return next_page_token, videos 89 | -------------------------------------------------------------------------------- /api/youtubedl.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | import os 3 | 4 | from django.conf import settings 5 | from yt_dlp import YoutubeDL 6 | from yt_dlp.utils import DownloadError 7 | from youtube_title_parse import get_artist_title 8 | 9 | """ 10 | This module contains functions related to downloading/parsing YouTube links with youtubedl. 11 | """ 12 | 13 | def get_file_ext(url): 14 | """ 15 | Get the file extension of the audio file that would be extracted from the 16 | given YouTube video URL. 17 | 18 | :param url: YouTube video URL 19 | """ 20 | opts = { 21 | # Always use the best audio quality available 22 | 'format': 'bestaudio/best', 23 | 'forcefilename': True, 24 | 'noplaylist': True, 25 | 'source_address': settings.YOUTUBEDL_SOURCE_ADDR, 26 | 'verbose': settings.YOUTUBEDL_VERBOSE 27 | } 28 | # Try up to 3 times as youtubedl tends to be flakey 29 | for _ in range(settings.YOUTUBE_MAX_RETRIES): 30 | try: 31 | with YoutubeDL(opts) as ydl: 32 | info = ydl.extract_info(url, download=False) 33 | filename = ydl.prepare_filename(info) 34 | _, file_extension = os.path.splitext(filename) 35 | return file_extension 36 | except DownloadError: 37 | # Allow for retry 38 | pass 39 | raise Exception('get_file_ext failed') 40 | 41 | def get_meta_info(url): 42 | """ 43 | Get metadata info from YouTube video. 44 | 45 | :param url: YouTube video URL 46 | """ 47 | opts = { 48 | 'format': 'bestaudio/best', 49 | 'forcefilename': True, 50 | 'noplaylist': True, 51 | 'source_address': settings.YOUTUBEDL_SOURCE_ADDR, 52 | 'verbose': settings.YOUTUBEDL_VERBOSE 53 | } 54 | # Try up to 3 times, as youtubedl tends to be flakey 55 | for _ in range(settings.YOUTUBE_MAX_RETRIES): 56 | try: 57 | with YoutubeDL(opts) as ydl: 58 | info = ydl.extract_info(url, download=False) 59 | filename = ydl.prepare_filename(info) 60 | 61 | parsed_artist = '' 62 | parsed_title = '' 63 | 64 | # Use youtube_title_parse library to attempt to parse the YouTube video title into 65 | # the track's artist and title. 66 | result = get_artist_title(info['title']) 67 | if result: 68 | parsed_artist, parsed_title = result 69 | 70 | metadata = { 71 | # YT video title 72 | 'title': info['title'], 73 | # YT video uploader 74 | 'uploader': info['uploader'], 75 | # YT video's embedded track artist (some official songs) 76 | 'embedded_artist': info['artist'] if 'artist' in info else '', 77 | # YT video's embedded track title (some official songs) 78 | 'embedded_title': info['track'] if 'track' in info else '', 79 | # Artist name parsed from the YouTube video title 80 | 'parsed_artist': parsed_artist, 81 | # Title parsed from the YouTube video title 82 | 'parsed_title': parsed_title, 83 | # Duration of YouTube video in seconds 84 | 'duration': info['duration'], 85 | # YouTube video URL 86 | 'url': info['webpage_url'], 87 | # Filename (including extension) 88 | 'filename': filename 89 | } 90 | return metadata 91 | except KeyError: 92 | pass 93 | except DownloadError: 94 | # Allow for retry 95 | pass 96 | raise DownloadError('Unable to parse YouTube link') 97 | 98 | def download_audio(url, dir_path): 99 | """ 100 | Extract audio track from YouTube video and save to given path. 101 | 102 | :param url: YouTube video URL 103 | :param dir_path: Path to save audio file 104 | """ 105 | opts = { 106 | 'format': 'bestaudio/best', 107 | 'forcefilename': True, 108 | 'outtmpl': str(dir_path), 109 | 'cachedir': False, 110 | 'noplaylist': True, 111 | 'source_address': settings.YOUTUBEDL_SOURCE_ADDR, 112 | 'verbose': settings.YOUTUBEDL_VERBOSE 113 | } 114 | 115 | # Retry mechanism is handled on Celery's side 116 | with YoutubeDL(opts) as ydl: 117 | info = ydl.extract_info(url, download=False) 118 | if info['duration'] > settings.YOUTUBE_LENGTH_LIMIT: 119 | raise Exception('Video length too long') 120 | ydl.download([url]) 121 | -------------------------------------------------------------------------------- /celery-fast-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CELERY_FAST_QUEUE_CONCURRENCY="${CELERY_FAST_QUEUE_CONCURRENCY:-3}" 4 | 5 | echo "Starting Celery (fast)" 6 | 7 | mkdir -p celery 8 | 9 | celery -A api worker -l INFO -Q fast_queue \ 10 | --concurrency $CELERY_FAST_QUEUE_CONCURRENCY \ 11 | --statedb=./celery/celery-fast.state 12 | -------------------------------------------------------------------------------- /celery-slow-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | CELERY_SLOW_QUEUE_CONCURRENCY="${CELERY_SLOW_QUEUE_CONCURRENCY:-1}" 4 | 5 | echo "Starting Celery (slow)" 6 | 7 | mkdir -p celery 8 | 9 | celery -A api worker -l INFO -Q slow_queue \ 10 | --concurrency $CELERY_SLOW_QUEUE_CONCURRENCY \ 11 | --statedb=./celery/celery-slow.state 12 | -------------------------------------------------------------------------------- /config/4stems-16kHz.json: -------------------------------------------------------------------------------- 1 | { 2 | "train_csv": "path/to/train.csv", 3 | "validation_csv": "path/to/val.csv", 4 | "model_dir": "4stems", 5 | "mix_name": "mix", 6 | "instrument_list": ["vocals", "drums", "bass", "other"], 7 | "sample_rate": 44100, 8 | "frame_length": 4096, 9 | "frame_step": 1024, 10 | "T": 512, 11 | "F": 1536, 12 | "n_channels": 2, 13 | "separation_exponent": 2, 14 | "mask_extension": "average", 15 | "learning_rate": 1e-4, 16 | "batch_size": 4, 17 | "training_cache": "training_cache", 18 | "validation_cache": "validation_cache", 19 | "train_max_steps": 1500000, 20 | "throttle_secs": 600, 21 | "random_seed": 3, 22 | "save_checkpoints_steps": 300, 23 | "save_summary_steps": 5, 24 | "model": { 25 | "type": "unet.unet", 26 | "params": { 27 | "conv_activation": "ELU", 28 | "deconv_activation": "ELU" 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /config/5stems-16kHz.json: -------------------------------------------------------------------------------- 1 | { 2 | "train_csv": "path/to/train.csv", 3 | "validation_csv": "path/to/test.csv", 4 | "model_dir": "5stems", 5 | "mix_name": "mix", 6 | "instrument_list": [ 7 | "vocals", 8 | "piano", 9 | "drums", 10 | "bass", 11 | "other" 12 | ], 13 | "sample_rate": 44100, 14 | "frame_length": 4096, 15 | "frame_step": 1024, 16 | "T": 512, 17 | "F": 1536, 18 | "n_channels": 2, 19 | "separation_exponent": 2, 20 | "mask_extension": "average", 21 | "learning_rate": 1e-4, 22 | "batch_size": 4, 23 | "training_cache": "training_cache", 24 | "validation_cache": "validation_cache", 25 | "train_max_steps": 2500000, 26 | "throttle_secs": 600, 27 | "random_seed": 8, 28 | "save_checkpoints_steps": 300, 29 | "save_summary_steps": 5, 30 | "model": { 31 | "type": "unet.softmax_unet", 32 | "params": { 33 | "conv_activation": "ELU", 34 | "deconv_activation": "ELU" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /config/d3net/bass.yaml: -------------------------------------------------------------------------------- 1 | valid_signal_idx: 1600 2 | band_split_idxs: [192] 3 | num_init_features: [32, 8] 4 | num_layer_blocks: [[5, 5, 5, 5, 4, 4, 4], [1, 1, 1, 1, 1, 1, 1]] 5 | dens_k: [[16, 18, 18, 20, 18, 16, 16], [2, 2, 2, 2, 2, 2, 2]] 6 | b_n_blocks: [[2, 2, 2, 2, 2, 2, 2], [1, 1, 1, 1, 1, 1, 1]] 7 | comp_rates: [[1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]] 8 | f_num_init_features: 32 9 | f_num_layer_block: [4, 5, 6, 7, 8, 6, 6, 4, 4] 10 | f_dens_k: [10, 10, 12, 14, 16, 14, 12, 8, 8] 11 | f_n_blocks: [2, 2, 2, 2, 2, 2, 2, 2, 2] 12 | f_comp_rates: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] 13 | ttl_num_layer_block: 3 14 | ttl_dens_k: 12 15 | patch_length: 352 16 | n_channels: 2 17 | fft_size: 4096 18 | hop_size: 1024 19 | fs: 44100 20 | test_patch_len: 384 21 | out_shift: 192 -------------------------------------------------------------------------------- /config/d3net/drums.yaml: -------------------------------------------------------------------------------- 1 | valid_signal_idx: 1600 2 | band_split_idxs: [128] 3 | num_init_features: [32, 8] 4 | num_layer_blocks: [[5, 5, 5, 4, 4, 4, 4], [1, 1, 1, 1, 1]] 5 | dens_k: [[16, 18, 20, 22, 20, 18, 16], [2, 2, 2, 2, 2]] 6 | b_n_blocks: [[2, 2, 2, 2, 2, 2, 2], [1, 1, 1, 1, 1]] 7 | comp_rates: [[1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [1.0, 1.0, 1.0, 1.0, 1.0]] 8 | f_num_init_features: 32 9 | f_num_layer_block: [4, 5, 6, 7, 8, 6, 6, 4, 4] 10 | f_dens_k: [13, 14, 15, 16, 16, 16, 14, 12, 11] 11 | f_n_blocks: [2, 2, 2, 2, 2, 2, 2, 2, 2] 12 | f_comp_rates: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] 13 | ttl_num_layer_block: 3 14 | ttl_dens_k: 12 15 | patch_length: 256 16 | n_channels: 2 17 | fft_size: 4096 18 | hop_size: 1024 19 | fs: 44100 20 | test_patch_len: 256 21 | out_shift: 128 -------------------------------------------------------------------------------- /config/d3net/other.yaml: -------------------------------------------------------------------------------- 1 | valid_signal_idx: 1600 2 | band_split_idxs: [256] 3 | num_init_features: [32, 8] 4 | num_layer_blocks: [[5, 5, 5, 5, 4, 4, 4], [1, 1, 1, 1, 1]] 5 | dens_k: [[16, 18, 20, 22, 20, 18, 16], [2, 2, 2, 2, 2]] 6 | b_n_blocks: [[2, 2, 2, 2, 2, 2, 2], [1, 1, 1, 1, 1]] 7 | comp_rates: [[1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]] 8 | f_num_init_features: 32 9 | f_num_layer_block: [4, 5, 6, 7, 8, 6, 5, 4, 4] 10 | f_dens_k: [13, 14, 15, 16, 17, 16, 14, 12, 11] 11 | f_n_blocks: [2, 2, 2, 2, 2, 2, 2, 2, 2] 12 | f_comp_rates: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] 13 | ttl_num_layer_block: 3 14 | ttl_dens_k: 12 15 | patch_length: 256 16 | n_channels: 2 17 | fft_size: 4096 18 | hop_size: 1024 19 | fs: 44100 20 | test_patch_len: 256 21 | out_shift: 128 -------------------------------------------------------------------------------- /config/d3net/vocals.yaml: -------------------------------------------------------------------------------- 1 | valid_signal_idx: 1600 2 | band_split_idxs: [256] 3 | num_init_features: [32, 8] 4 | num_layer_blocks: [[5, 5, 5, 5, 4, 4, 4], [1, 1, 1, 1, 1]] 5 | dens_k: [[16, 18, 20, 22, 20, 18, 16], [2, 2, 2, 2, 2]] 6 | b_n_blocks: [[2, 2, 2, 2, 2, 2, 2], [1, 1, 1, 1, 1]] 7 | comp_rates: [[1.0, 1.0, 1.0, 1.0, 1.0, 1.0], [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]] 8 | f_num_init_features: 32 9 | f_num_layer_block: [4, 5, 6, 7, 8, 6, 5, 4, 4] 10 | f_dens_k: [13, 14, 15, 16, 17, 16, 14, 12, 11] 11 | f_n_blocks: [2, 2, 2, 2, 2, 2, 2, 2, 2] 12 | f_comp_rates: [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0] 13 | ttl_num_layer_block: 3 14 | ttl_dens_k: 12 15 | patch_length: 256 16 | n_channels: 2 17 | fft_size: 4096 18 | hop_size: 1024 19 | fs: 44100 20 | test_patch_len: 256 21 | out_shift: 128 -------------------------------------------------------------------------------- /django_react/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeffreyCA/spleeter-web/945b3c48eec5d55998497dc817641a1a1e9ad937/django_react/__init__.py -------------------------------------------------------------------------------- /django_react/asgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | ASGI config for django_react project. 3 | 4 | It exposes the ASGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/asgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.asgi import get_asgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_react.settings') 15 | 16 | application = get_asgi_application() 17 | -------------------------------------------------------------------------------- /django_react/middleware.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | class COEPCOOPHeadersMiddleware: 4 | enable_headers = bool(int(os.getenv('ENABLE_CROSS_ORIGIN_HEADERS', '0'))) 5 | 6 | def __init__(self, get_response): 7 | self.get_response = get_response 8 | 9 | def __call__(self, request): 10 | response = self.get_response(request) 11 | if self.enable_headers: 12 | response["Cross-Origin-Embedder-Policy"] = 'require-corp' 13 | response["Cross-Origin-Opener-Policy"] = 'same-origin' 14 | return response 15 | -------------------------------------------------------------------------------- /django_react/settings_dev.py: -------------------------------------------------------------------------------- 1 | # SECURITY WARNING: don't run with debug turned on in production! 2 | DEBUG = True 3 | 4 | SECRET_KEY = 'default' 5 | 6 | ALLOWED_HOSTS = ['*'] 7 | 8 | # DEFAULT_FILE_STORAGE = 'api.storage.FileSystemStorage' 9 | # DEFAULT_FILE_STORAGE = 'api.storage.S3Boto3Storage' 10 | # DEFAULT_FILE_STORAGE = 'api.storage.AzureStorage' 11 | 12 | STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 13 | 14 | REST_FRAMEWORK = { 15 | 'DEFAULT_RENDERER_CLASSES': [ 16 | 'rest_framework.renderers.JSONRenderer', 17 | 'rest_framework.renderers.BrowsableAPIRenderer', 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /django_react/settings_docker_dev.py: -------------------------------------------------------------------------------- 1 | # SECURITY WARNING: don't run with debug turned on in production! 2 | DEBUG = True 3 | 4 | SECRET_KEY = 'default' 5 | 6 | ALLOWED_HOSTS = ['*'] 7 | 8 | # DEFAULT_FILE_STORAGE = 'api.storage.FileSystemStorage' 9 | # DEFAULT_FILE_STORAGE = 'api.storage.S3Boto3Storage' 10 | # DEFAULT_FILE_STORAGE = 'api.storage.AzureStorage' 11 | 12 | STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' 13 | 14 | REST_FRAMEWORK = { 15 | 'DEFAULT_RENDERER_CLASSES': [ 16 | 'rest_framework.renderers.JSONRenderer', 17 | 'rest_framework.renderers.BrowsableAPIRenderer', 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /django_react/urls.py: -------------------------------------------------------------------------------- 1 | """django_react URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/3.0/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import path, include 18 | 19 | urlpatterns = [ 20 | path('admin/', admin.site.urls), 21 | path('', include('api.urls')), 22 | path('', include('frontend.urls')), 23 | ] 24 | -------------------------------------------------------------------------------- /django_react/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for django_react project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/3.0/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_react.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /docker-compose.build.gpu.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | build: ./nginx 4 | api: 5 | build: 6 | context: . 7 | dockerfile: gpu.Dockerfile 8 | celery-fast: 9 | build: 10 | context: . 11 | dockerfile: gpu.Dockerfile 12 | celery-slow: 13 | build: 14 | context: . 15 | dockerfile: gpu.Dockerfile 16 | frontend: 17 | build: ./frontend 18 | -------------------------------------------------------------------------------- /docker-compose.build.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | build: ./nginx 4 | api: 5 | build: . 6 | celery-fast: 7 | build: . 8 | celery-slow: 9 | build: . 10 | frontend: 11 | build: ./frontend 12 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | entrypoint: ["echo", "Service nginx disabled"] 4 | restart: "no" 5 | api: 6 | ports: 7 | - "${DEV_WEBSERVER_PORT:-8000}:8000" 8 | environment: 9 | - DJANGO_DEVELOPMENT=true 10 | - API_HOST=0.0.0.0 11 | volumes: 12 | - ./api:/webapp/api 13 | - ./media:/webapp/media 14 | celery-fast: 15 | environment: 16 | - DJANGO_DEVELOPMENT=true 17 | volumes: 18 | - ./api:/webapp/api 19 | - ./media:/webapp/media 20 | celery-slow: 21 | environment: 22 | - DJANGO_DEVELOPMENT=true 23 | volumes: 24 | - ./api:/webapp/api 25 | - ./media:/webapp/media 26 | frontend: 27 | environment: 28 | - DJANGO_DEVELOPMENT=true 29 | volumes: 30 | - ./frontend/src:/webapp/frontend/src 31 | restart: always 32 | -------------------------------------------------------------------------------- /docker-compose.gpu.yml: -------------------------------------------------------------------------------- 1 | x-celery-env: &celery-env 2 | - TF_CPP_MIN_LOG_LEVEL=2 3 | - DJANGO_SETTINGS_MODULE=django_react.settings_docker 4 | - CELERY_BROKER_URL=redis://redis:6379/0 5 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 6 | - CPU_SEPARATION=0 7 | - CELERY_FAST_QUEUE_CONCURRENCY=${CELERY_FAST_QUEUE_CONCURRENCY:-3} 8 | - CELERY_SLOW_QUEUE_CONCURRENCY=${CELERY_SLOW_QUEUE_CONCURRENCY:-1} 9 | - DEFAULT_FILE_STORAGE 10 | - AWS_ACCESS_KEY_ID 11 | - AWS_SECRET_ACCESS_KEY 12 | - AWS_STORAGE_BUCKET_NAME 13 | - AWS_S3_CUSTOM_DOMAIN 14 | - AWS_S3_REGION_NAME 15 | - AWS_S3_SIGNATURE_VERSION 16 | - AZURE_ACCOUNT_KEY 17 | - AZURE_ACCOUNT_NAME 18 | - AZURE_CONTAINER 19 | - AZURE_CUSTOM_DOMAIN 20 | - D3NET_OPENVINO=0 21 | - NVIDIA_VISIBLE_DEVICES=all 22 | - NVIDIA_DRIVER_CAPABILITIES=all 23 | - PYTORCH_NO_CUDA_MEMORY_CACHING 24 | - DEMUCS_SEGMENT_SIZE 25 | - YOUTUBE_LENGTH_LIMIT 26 | - YOUTUBEDL_SOURCE_ADDR 27 | - YOUTUBEDL_VERBOSE 28 | services: 29 | redis: 30 | image: redis:6.0-buster 31 | hostname: redis 32 | expose: 33 | - "6379" 34 | volumes: 35 | - redis-data:/data 36 | restart: always 37 | nginx: 38 | image: jeffreyca/spleeter-web-nginx:${TAG:-latest} 39 | volumes: 40 | - ./nginx/templates:/etc/nginx/templates 41 | - staticfiles:/webapp/staticfiles 42 | environment: 43 | - API_HOST=api 44 | - CERTBOT_CERT_NAME=spleeter-web 45 | - CERTBOT_EMAIL=. # Dummy email (overridden by https.yml) 46 | - CERTBOT_DOMAIN=. 47 | - UPLOAD_FILE_SIZE_LIMIT=${UPLOAD_FILE_SIZE_LIMIT:-100} 48 | depends_on: 49 | - api 50 | restart: always 51 | api: 52 | image: jeffreyca/spleeter-web-backend:${TAG:-latest}-gpu 53 | volumes: 54 | - assets:/webapp/frontend/assets 55 | - sqlite-data:/webapp/sqlite 56 | - staticfiles:/webapp/staticfiles 57 | stdin_open: true 58 | tty: true 59 | environment: 60 | - TF_CPP_MIN_LOG_LEVEL=2 61 | - DJANGO_SETTINGS_MODULE=django_react.settings_docker 62 | - CELERY_BROKER_URL=redis://redis:6379/0 63 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 64 | - CPU_SEPARATION=0 65 | - ALLOW_ALL_HOSTS 66 | - APP_HOST 67 | - DEFAULT_FILE_STORAGE 68 | - ENABLE_CROSS_ORIGIN_HEADERS 69 | - AWS_ACCESS_KEY_ID 70 | - AWS_SECRET_ACCESS_KEY 71 | - AWS_STORAGE_BUCKET_NAME 72 | - AWS_S3_CUSTOM_DOMAIN 73 | - AWS_S3_REGION_NAME 74 | - AWS_S3_SIGNATURE_VERSION 75 | - AZURE_ACCOUNT_KEY 76 | - AZURE_ACCOUNT_NAME 77 | - AZURE_CONTAINER 78 | - AZURE_CUSTOM_DOMAIN 79 | - YOUTUBE_API_KEY 80 | - UPLOAD_FILE_SIZE_LIMIT 81 | - YOUTUBE_LENGTH_LIMIT 82 | - YOUTUBEDL_SOURCE_ADDR 83 | - YOUTUBEDL_VERBOSE 84 | depends_on: 85 | - redis 86 | - frontend 87 | restart: always 88 | celery-fast: 89 | image: jeffreyca/spleeter-web-backend:${TAG:-latest}-gpu 90 | entrypoint: ./celery-fast-entrypoint.sh 91 | volumes: 92 | - celery-data:/webapp/celery 93 | - pretrained-models:/webapp/pretrained_models 94 | - sqlite-data:/webapp/sqlite 95 | deploy: 96 | resources: 97 | reservations: 98 | devices: 99 | - capabilities: [gpu] 100 | environment: *celery-env 101 | depends_on: 102 | - redis 103 | restart: always 104 | celery-slow: 105 | image: jeffreyca/spleeter-web-backend:${TAG:-latest}-gpu 106 | entrypoint: ./celery-slow-entrypoint.sh 107 | volumes: 108 | - celery-data:/webapp/celery 109 | - pretrained-models:/webapp/pretrained_models 110 | - sqlite-data:/webapp/sqlite 111 | deploy: 112 | resources: 113 | reservations: 114 | devices: 115 | - capabilities: [gpu] 116 | environment: *celery-env 117 | depends_on: 118 | - redis 119 | restart: always 120 | frontend: 121 | image: jeffreyca/spleeter-web-frontend:${TAG:-latest} 122 | volumes: 123 | - assets:/webapp/frontend/assets 124 | stdin_open: true 125 | tty: true 126 | volumes: 127 | assets: 128 | celery-data: 129 | pretrained-models: 130 | redis-data: 131 | sqlite-data: 132 | staticfiles: 133 | -------------------------------------------------------------------------------- /docker-compose.https.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | restart: unless-stopped 4 | environment: 5 | - API_HOST=api 6 | - ENABLE_HTTPS=1 7 | - APP_HOST 8 | - CERTBOT_CERT_NAME=spleeter-web 9 | - CERTBOT_DOMAIN 10 | - CERTBOT_EMAIL 11 | - UPLOAD_FILE_SIZE_LIMIT=${UPLOAD_FILE_SIZE_LIMIT:-100} 12 | volumes: 13 | - ./certbot/conf/:/etc/letsencrypt 14 | -------------------------------------------------------------------------------- /docker-compose.prod.selfhost.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | volumes: 4 | - ./media:/webapp/media 5 | api: 6 | volumes: 7 | - ./media:/webapp/media 8 | celery-fast: 9 | volumes: 10 | - ./media:/webapp/media 11 | celery-slow: 12 | volumes: 13 | - ./media:/webapp/media 14 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | services: 2 | nginx: 3 | ports: 4 | - "${NGINX_PORT:-80}:80" 5 | - "${NGINX_PORT_SSL:-443}:443" 6 | api: 7 | expose: 8 | - "8000" 9 | frontend: 10 | restart: "no" 11 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | x-celery-env: &celery-env 2 | - TF_CPP_MIN_LOG_LEVEL=2 3 | - DJANGO_SETTINGS_MODULE=django_react.settings_docker 4 | - CELERY_BROKER_URL=redis://redis:6379/0 5 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 6 | - CPU_SEPARATION=1 7 | - CELERY_FAST_QUEUE_CONCURRENCY=${CELERY_FAST_QUEUE_CONCURRENCY:-3} 8 | - CELERY_SLOW_QUEUE_CONCURRENCY=${CELERY_SLOW_QUEUE_CONCURRENCY:-1} 9 | - DEFAULT_FILE_STORAGE 10 | - AWS_ACCESS_KEY_ID 11 | - AWS_SECRET_ACCESS_KEY 12 | - AWS_STORAGE_BUCKET_NAME 13 | - AWS_S3_CUSTOM_DOMAIN 14 | - AWS_S3_REGION_NAME 15 | - AWS_S3_SIGNATURE_VERSION 16 | - AZURE_ACCOUNT_KEY 17 | - AZURE_ACCOUNT_NAME 18 | - AZURE_CONTAINER 19 | - AZURE_CUSTOM_DOMAIN 20 | - D3NET_OPENVINO 21 | - D3NET_OPENVINO_THREADS 22 | - YOUTUBE_LENGTH_LIMIT 23 | - YOUTUBEDL_SOURCE_ADDR 24 | - YOUTUBEDL_VERBOSE 25 | services: 26 | redis: 27 | image: redis:6.0-buster 28 | hostname: redis 29 | expose: 30 | - "6379" 31 | volumes: 32 | - redis-data:/data 33 | restart: always 34 | nginx: 35 | image: jeffreyca/spleeter-web-nginx:${TAG:-latest} 36 | volumes: 37 | - ./nginx/templates:/etc/nginx/templates 38 | - staticfiles:/webapp/staticfiles 39 | environment: 40 | - API_HOST=api 41 | - CERTBOT_CERT_NAME=spleeter-web 42 | - CERTBOT_DOMAIN=. 43 | - CERTBOT_EMAIL=. # Dummy email (overridden by https.yml) 44 | - UPLOAD_FILE_SIZE_LIMIT=${UPLOAD_FILE_SIZE_LIMIT:-100} 45 | depends_on: 46 | - api 47 | restart: always 48 | api: 49 | image: jeffreyca/spleeter-web-backend:${TAG:-latest} 50 | volumes: 51 | - assets:/webapp/frontend/assets 52 | - sqlite-data:/webapp/sqlite 53 | - staticfiles:/webapp/staticfiles 54 | stdin_open: true 55 | tty: true 56 | environment: 57 | - TF_CPP_MIN_LOG_LEVEL=2 58 | - DJANGO_SETTINGS_MODULE=django_react.settings_docker 59 | - CELERY_BROKER_URL=redis://redis:6379/0 60 | - CELERY_RESULT_BACKEND=redis://redis:6379/0 61 | - CPU_SEPARATION=1 62 | - ALLOW_ALL_HOSTS 63 | - APP_HOST 64 | - DEFAULT_FILE_STORAGE 65 | - ENABLE_CROSS_ORIGIN_HEADERS 66 | - AWS_ACCESS_KEY_ID 67 | - AWS_SECRET_ACCESS_KEY 68 | - AWS_STORAGE_BUCKET_NAME 69 | - AWS_S3_CUSTOM_DOMAIN 70 | - AWS_S3_REGION_NAME 71 | - AWS_S3_SIGNATURE_VERSION 72 | - AZURE_ACCOUNT_KEY 73 | - AZURE_ACCOUNT_NAME 74 | - AZURE_CONTAINER 75 | - AZURE_CUSTOM_DOMAIN 76 | - YOUTUBE_API_KEY 77 | - UPLOAD_FILE_SIZE_LIMIT 78 | - YOUTUBE_LENGTH_LIMIT 79 | - YOUTUBEDL_SOURCE_ADDR 80 | - YOUTUBEDL_VERBOSE 81 | depends_on: 82 | - redis 83 | - frontend 84 | restart: always 85 | celery-fast: 86 | image: jeffreyca/spleeter-web-backend:${TAG:-latest} 87 | entrypoint: ./celery-fast-entrypoint.sh 88 | volumes: 89 | - celery-data:/webapp/celery 90 | - pretrained-models:/webapp/pretrained_models 91 | - sqlite-data:/webapp/sqlite 92 | environment: *celery-env 93 | depends_on: 94 | - redis 95 | dns: 96 | - "8.8.8.8" 97 | restart: always 98 | celery-slow: 99 | image: jeffreyca/spleeter-web-backend:${TAG:-latest} 100 | entrypoint: ./celery-slow-entrypoint.sh 101 | volumes: 102 | - celery-data:/webapp/celery 103 | - pretrained-models:/webapp/pretrained_models 104 | - sqlite-data:/webapp/sqlite 105 | environment: *celery-env 106 | depends_on: 107 | - redis 108 | restart: always 109 | frontend: 110 | image: jeffreyca/spleeter-web-frontend:${TAG:-latest} 111 | volumes: 112 | - assets:/webapp/frontend/assets 113 | stdin_open: true 114 | tty: true 115 | volumes: 116 | assets: 117 | celery-data: 118 | pretrained-models: 119 | redis-data: 120 | sqlite-data: 121 | staticfiles: 122 | -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-proposal-class-properties" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | *Dockerfile* 2 | *docker-compose* 3 | 4 | node_modules 5 | assets 6 | webpack-stats.json 7 | -------------------------------------------------------------------------------- /frontend/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "es6": true, 5 | "node": true, 6 | "browser": true 7 | }, 8 | "parser": "@typescript-eslint/parser", 9 | "parserOptions": { 10 | "ecmaVersion": 6, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "jsx": true 14 | } 15 | }, 16 | "plugins": [ 17 | "@typescript-eslint" 18 | ], 19 | "rules": { 20 | "curly": "warn", 21 | "eqeqeq": "warn", 22 | "no-throw-literal": "warn", 23 | "no-use-before-define": "off", 24 | "no-return-await": "warn", 25 | "semi": "off", 26 | "@typescript-eslint/semi": [ 27 | "error" 28 | ], 29 | "@typescript-eslint/no-use-before-define": "off" 30 | }, 31 | "extends": [ 32 | "eslint:recommended", 33 | "plugin:react/recommended", 34 | "plugin:@typescript-eslint/recommended", 35 | "prettier/@typescript-eslint", 36 | "plugin:prettier/recommended" 37 | ], 38 | "settings": { 39 | "react": { 40 | "version": "detect" 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | assets/ 3 | webpack-stats.json 4 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "jsxBracketSameLine": true, 4 | "printWidth": 120, 5 | "quoteProps": "consistent", 6 | "semi": true, 7 | "singleQuote": true, 8 | "tabWidth": 2, 9 | "trailingComma": "es5" 10 | } 11 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-bullseye 2 | 3 | RUN mkdir -p /webapp/frontend/assets 4 | WORKDIR /webapp/frontend 5 | 6 | # Install Node dependencies 7 | COPY package.json /webapp/frontend/ 8 | COPY package-lock.json /webapp/frontend/ 9 | RUN npm install 10 | 11 | COPY . . 12 | 13 | # Copy over entrypoint script 14 | COPY frontend-entrypoint.sh /usr/local/bin/ 15 | RUN chmod +x /usr/local/bin/frontend-entrypoint.sh 16 | RUN ln -s /usr/local/bin/frontend-entrypoint.sh / 17 | ENTRYPOINT ["frontend-entrypoint.sh"] 18 | -------------------------------------------------------------------------------- /frontend/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JeffreyCA/spleeter-web/945b3c48eec5d55998497dc817641a1a1e9ad937/frontend/__init__.py -------------------------------------------------------------------------------- /frontend/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | class FrontendConfig(AppConfig): 4 | name = 'frontend' 5 | -------------------------------------------------------------------------------- /frontend/context_processors.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def debug(context): 4 | return {'DJANGO_DEVELOPMENT': os.getenv('DJANGO_DEVELOPMENT')} 5 | -------------------------------------------------------------------------------- /frontend/frontend-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Starting frontend" 4 | rm -rf assets/dist 5 | if [[ -z "${DJANGO_DEVELOPMENT}" ]]; then 6 | npm run build 7 | else 8 | npm run dev 9 | fi 10 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "spleeter-web-frontend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack --config webpack.prod.config.js", 8 | "dev": "webpack --config webpack.dev.config.js --watch", 9 | "prod": "webpack --config webpack.prod.config.js --watch" 10 | }, 11 | "keywords": [], 12 | "author": "", 13 | "license": "MIT", 14 | "devDependencies": { 15 | "@babel/core": "^7.27.1", 16 | "@babel/plugin-proposal-class-properties": "^7.18.6", 17 | "@babel/preset-env": "^7.27.2", 18 | "@babel/preset-react": "^7.27.1", 19 | "@babel/preset-typescript": "^7.27.1", 20 | "@types/bootstrap": "^4.6.6", 21 | "@types/he": "^1.2.3", 22 | "@types/luxon": "^2.4.0", 23 | "@types/node": "^14.18.63", 24 | "@types/react": "^16.14.63", 25 | "@types/react-bootstrap": "^0.32.37", 26 | "@types/react-bootstrap-table-next": "^4.0.26", 27 | "@types/react-dom": "^16.9.25", 28 | "@types/react-router-dom": "^5.3.3", 29 | "@types/react-slider": "^1.3.6", 30 | "@typescript-eslint/eslint-plugin": "^3.10.1", 31 | "@typescript-eslint/parser": "^3.10.1", 32 | "bootstrap": "^4.6.2", 33 | "clean-webpack-plugin": "^3.0.0", 34 | "copy-webpack-plugin": "^6.4.1", 35 | "css-loader": "^5.2.7", 36 | "eslint": "^7.32.0", 37 | "eslint-config-prettier": "^6.15.0", 38 | "eslint-plugin-prettier": "^3.4.1", 39 | "eslint-plugin-react": "^7.37.5", 40 | "postcss-loader": "^4.3.0", 41 | "prettier": "^2.8.8", 42 | "style-loader": "^2.0.0", 43 | "terser-webpack-plugin": "^2.3.8", 44 | "url-loader": "^4.1.1", 45 | "webpack": "^4.47.0", 46 | "webpack-bundle-tracker": "^1.8.1", 47 | "webpack-cli": "^4.10.0" 48 | }, 49 | "dependencies": { 50 | "@jeffreyca/ffmpeg": "^0.10.3", 51 | "@jeffreyca/ffmpeg.wasm-core": "^0.11.0", 52 | "@jeffreyca/react-dropzone-uploader": "^2.11.10", 53 | "@jeffreyca/react-jinke-music-player": "^4.22.1", 54 | "awesome-debounce-promise": "^2.1.0", 55 | "axios": "^0.30.0", 56 | "babel-loader": "^8.4.1", 57 | "babel-runtime": "^6.26.0", 58 | "he": "^1.2.0", 59 | "html5-file-selector": "^2.1.0", 60 | "jquery": "^3.7.1", 61 | "luxon": "^2.5.2", 62 | "react": "^16.14.0", 63 | "react-bootstrap": "^1.6.8", 64 | "react-bootstrap-icons": "^1.11.5", 65 | "react-bootstrap-table-next": "^4.0.3", 66 | "react-bootstrap-table2-editor": "^1.4.0", 67 | "react-dom": "^16.14.0", 68 | "react-router": "^5.3.4", 69 | "react-router-dom": "^5.3.4", 70 | "react-slider": "^1.3.3", 71 | "tone": "14.8.26", 72 | "typescript": "^4.9.5" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/Constants.tsx: -------------------------------------------------------------------------------- 1 | export const DEFAULT_MODEL = 'spleeter'; 2 | export const DEFAULT_MODEL_FAMILY = 'spleeter'; 3 | export const DEFAULT_SPLEETER_MODEL = 'spleeter'; 4 | export const DEFAULT_DEMUCS_MODEL = 'htdemucs'; 5 | 6 | export const LOSSY_OUTPUT_FORMATS: [number, string][] = [ 7 | [192, '192 kbps'], 8 | [256, '256 kbps'], 9 | [320, '320 kbps'], 10 | ]; 11 | // Reserve 0 and 1 for backcompat 12 | export const LOSSLESS_OUTPUT_FORMATS: [number, string][] = [ 13 | [0, 'WAV'], 14 | [1, 'FLAC'], 15 | ]; 16 | export const DEFAULT_OUTPUT_FORMAT = 256; 17 | export const DEFAULT_SOFTMASK_ALPHA = 1.0; 18 | export const MAX_SOFTMASK_ALPHA = 2.0; 19 | export const MIN_SOFTMASK_ALPHA = 0.1; 20 | export const MAX_SHIFT_ITER = 50; 21 | export const FADE_DURATION_MS = 300; 22 | export const FADE_DURATION_S = 0.3; 23 | 24 | export const ALLOWED_EXTENSIONS = [ 25 | // Lossless 26 | '.aif', 27 | '.aifc', 28 | '.aiff', 29 | '.flac', 30 | '.wav', 31 | // Lossy 32 | '.aac', 33 | '.m4a', 34 | '.mp3', 35 | '.opus', 36 | '.weba', 37 | '.webm', 38 | // Ogg-Vorbis (Lossy) 39 | '.ogg', 40 | '.oga', 41 | '.mogg', 42 | ]; 43 | -------------------------------------------------------------------------------- /frontend/src/Utils.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { DateTime, Duration } from 'luxon'; 3 | 4 | const SEC_PER_MIN = 60; 5 | const DAYS_PER_WEEK = 7; 6 | 7 | /** 8 | * Convert ISO date string to local date string 9 | */ 10 | export const toLocaleDateTimeString = (isoDateString: string): string => { 11 | const dateTime = DateTime.fromISO(isoDateString); 12 | const dateTimeStr = dateTime.toLocaleString(DateTime.DATETIME_SHORT); 13 | return dateTimeStr; 14 | }; 15 | 16 | /** 17 | * Convert ISO date string to custom format (hybrid of relative and absolute date) 18 | */ 19 | export const toRelativeDateSpan = (isoDateString: string): JSX.Element => { 20 | const dateTime = DateTime.fromISO(isoDateString); 21 | const dateTimeStr = dateTime.toLocaleString(DateTime.DATETIME_SHORT); 22 | const diffSeconds = -dateTime.diffNow().as('seconds'); 23 | const diffDays = -dateTime.diffNow().as('days'); 24 | let label; 25 | 26 | if (diffSeconds < SEC_PER_MIN) { 27 | label = 'moments ago'; 28 | } else if (diffDays < DAYS_PER_WEEK) { 29 | label = dateTime.toRelative(); 30 | } else { 31 | label = dateTime.toLocaleString(DateTime.DATE_MED); 32 | } 33 | return {label}; 34 | }; 35 | 36 | /** 37 | * Convert ISO duration string to (hh):mm:ss. 38 | * @param isoDuration ISO duration string 39 | */ 40 | export const toDurationTimestamp = (isoDuration: string): string => { 41 | const duration = Duration.fromISO(isoDuration); 42 | 43 | if (duration.as('hour') < 1) { 44 | return Duration.fromISO(isoDuration).toFormat('mm:ss'); 45 | } else { 46 | return Duration.fromISO(isoDuration).toFormat('hh:mm:ss'); 47 | } 48 | }; 49 | 50 | // Credit to @kenfehling/react-designable-audio-player 51 | export const zeroPadNumber = (number: number): string => { 52 | return number < 10 ? '0' + number : number.toString(); 53 | }; 54 | 55 | export const formatTime = (seconds: number): string => { 56 | if (typeof seconds === 'number') { 57 | seconds = Math.floor(seconds); 58 | if (seconds > 0) { 59 | const m = Math.floor(seconds / 60); 60 | const s = Math.floor(seconds - m * 60); 61 | return zeroPadNumber(m) + ':' + zeroPadNumber(s); 62 | } 63 | } 64 | return '00:00'; 65 | }; 66 | 67 | /** 68 | * Returns full YouTube URL for given video ID. 69 | * @param id Video ID 70 | */ 71 | export const getYouTubeLinkForId = (id: string): string => { 72 | return `https://www.youtube.com/watch?v=${id}`; 73 | }; 74 | -------------------------------------------------------------------------------- /frontend/src/components/Badges.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Badge } from 'react-bootstrap'; 3 | 4 | interface BadgeProps { 5 | className?: string; 6 | faded?: boolean; 7 | title?: string; 8 | } 9 | 10 | export const OriginalBadge = (props: BadgeProps): JSX.Element => { 11 | const { title } = props; 12 | return ( 13 | 14 | Original 15 | 16 | ); 17 | }; 18 | 19 | export const AllBadge = (): JSX.Element => { 20 | return ( 21 | 22 | All (Dynamic Mix) 23 | 24 | ); 25 | }; 26 | 27 | export const VocalsBadge = (props: BadgeProps): JSX.Element => { 28 | const { faded, title } = props; 29 | return ( 30 | 31 | Vocals 32 | 33 | ); 34 | }; 35 | 36 | export const AccompBadge = (props: BadgeProps): JSX.Element => { 37 | const { faded, title } = props; 38 | return ( 39 | 40 | Accompaniment 41 | 42 | ); 43 | }; 44 | 45 | export const AccompShortBadge = (props: BadgeProps): JSX.Element => { 46 | const { faded, title } = props; 47 | return ( 48 | 49 | Accomp. 50 | 51 | ); 52 | }; 53 | 54 | export const PianoBadge = (props: BadgeProps): JSX.Element => { 55 | const { faded, title } = props; 56 | return ( 57 | 58 | Piano 59 | 60 | ); 61 | }; 62 | 63 | export const DrumsBadge = (props: BadgeProps): JSX.Element => { 64 | const { faded, title } = props; 65 | return ( 66 | 67 | Drums 68 | 69 | ); 70 | }; 71 | 72 | export const BassBadge = (props: BadgeProps): JSX.Element => { 73 | const { faded, title } = props; 74 | return ( 75 | 76 | Bass 77 | 78 | ); 79 | }; 80 | -------------------------------------------------------------------------------- /frontend/src/components/Home/AutoRefreshButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { ArrowClockwise } from 'react-bootstrap-icons'; 4 | 5 | interface Props { 6 | period: number; 7 | onRefresh: () => Promise; 8 | } 9 | 10 | interface State { 11 | isRefreshing: boolean; 12 | seconds: number; 13 | } 14 | 15 | /** 16 | * Refreshing actually takes very little time, causing the refresh button to briefly flicker. 17 | * This controls how long to delay a "fake" refresh. 18 | */ 19 | const fakeRefreshDuration = 250; 20 | 21 | /** 22 | * Component for an auto-refresh button. 23 | */ 24 | class AutoRefreshButton extends React.Component { 25 | tickInterval?: number; 26 | 27 | constructor(props: Props) { 28 | super(props); 29 | this.state = { 30 | isRefreshing: false, 31 | seconds: props.period, 32 | }; 33 | } 34 | 35 | /** 36 | * Function called once per tick (every second). 37 | */ 38 | tick = async (): Promise => { 39 | const { period } = this.props; 40 | const { isRefreshing, seconds } = this.state; 41 | 42 | if (isRefreshing) { 43 | return; 44 | } 45 | 46 | // Time for refresh 47 | if (seconds <= 0) { 48 | // Stop interval 49 | clearInterval(this.tickInterval); 50 | this.setState({ 51 | isRefreshing: true, 52 | }); 53 | // Invoke callback and wait for it to resolve 54 | await this.props.onRefresh(); 55 | // Reset ticks 56 | this.setState({ 57 | isRefreshing: false, 58 | seconds: period, 59 | }); 60 | // Restart interval 61 | this.tickInterval = setInterval(this.tick, 1000); 62 | } else { 63 | // Decrement ticks 64 | this.setState({ 65 | seconds: this.state.seconds - 1, 66 | }); 67 | } 68 | }; 69 | 70 | refreshNow = async (): Promise => { 71 | const { period } = this.props; 72 | const { isRefreshing } = this.state; 73 | 74 | if (isRefreshing) { 75 | return; 76 | } 77 | 78 | // Stop interval 79 | clearInterval(this.tickInterval); 80 | this.setState({ 81 | isRefreshing: true, 82 | }); 83 | // Invoke callback and wait for it to resolve 84 | await this.props.onRefresh(); 85 | // Make manual refresh appear longer... 86 | await new Promise(resolve => setTimeout(resolve, fakeRefreshDuration)); 87 | // Reset ticks 88 | this.setState({ 89 | isRefreshing: false, 90 | seconds: period, 91 | }); 92 | // Restart interval 93 | this.tickInterval = setInterval(this.tick, 1000); 94 | }; 95 | 96 | componentDidMount(): void { 97 | // Tick every second 98 | this.tickInterval = setInterval(this.tick, 1000); 99 | } 100 | 101 | componentWillUnmount(): void { 102 | clearInterval(this.tickInterval); 103 | } 104 | 105 | render(): JSX.Element { 106 | const { isRefreshing, seconds } = this.state; 107 | const appearRefreshing = isRefreshing || seconds <= 0; 108 | const text = !appearRefreshing ? `Refreshing in ${seconds}` : 'Refreshing...'; 109 | 110 | return ( 111 |
112 |
{text}
113 | 122 |
123 | ); 124 | } 125 | } 126 | 127 | export default AutoRefreshButton; 128 | -------------------------------------------------------------------------------- /frontend/src/components/Home/Home.css: -------------------------------------------------------------------------------- 1 | .badge-vocals { 2 | background-color: #7c56c9; 3 | color: white; 4 | } 5 | 6 | .badge-accomp { 7 | background-color: #02acb4; 8 | color: white; 9 | } 10 | 11 | .badge-piano { 12 | background-color: #000000; 13 | color: white; 14 | } 15 | 16 | .badge-drums { 17 | background-color: #a8110d; 18 | color: white; 19 | } 20 | 21 | .badge-bass { 22 | background-color: #095ba4; 23 | color: white; 24 | } 25 | 26 | .badge-vocals-faded { 27 | background-color: #7c56c92c; 28 | color: white; 29 | } 30 | 31 | .badge-accomp-faded { 32 | background-color: #02abb42c; 33 | color: white; 34 | } 35 | 36 | .badge-piano-faded { 37 | background-color: #5454542c; 38 | color: white; 39 | } 40 | 41 | .badge-drums-faded { 42 | background-color: #a8120d2a; 43 | color: white; 44 | } 45 | 46 | .badge-bass-faded { 47 | background-color: #095ca428; 48 | color: white; 49 | } 50 | 51 | .refresher { 52 | display: flex; 53 | justify-content: flex-end; 54 | } 55 | -------------------------------------------------------------------------------- /frontend/src/components/Home/MusicPlayer.tsx: -------------------------------------------------------------------------------- 1 | import ReactJkMusicPlayer, { 2 | ReactJkMusicPlayerAudioInfo, 3 | ReactJkMusicPlayerAudioListProps, 4 | ReactJkMusicPlayerCustomLocale, 5 | } from '@jeffreyca/react-jinke-music-player'; 6 | import * as React from 'react'; 7 | import { Badge } from 'react-bootstrap'; 8 | import { FADE_DURATION_MS } from '../../Constants'; 9 | import { separatorLabelMap } from '../../models/Separator'; 10 | import { SongData } from '../../models/SongData'; 11 | import { StaticMix } from '../../models/StaticMix'; 12 | import { AccompShortBadge, BassBadge, DrumsBadge, OriginalBadge, PianoBadge, VocalsBadge } from '../Badges'; 13 | import './MusicPlayer.css'; 14 | 15 | interface Props { 16 | songData?: SongData; 17 | staticMix?: StaticMix; 18 | getAudioInstance: (instance: HTMLAudioElement) => void; 19 | onAudioPause: (audioInfo: ReactJkMusicPlayerAudioInfo) => void; 20 | onAudioPlay: (audioInfo: ReactJkMusicPlayerAudioInfo) => void; 21 | } 22 | 23 | /** 24 | * Music player component that controls audio playback. It shows up as a horizontal bar at the 25 | * bottom of the screen. 26 | */ 27 | class MusicPlayer extends React.Component { 28 | render(): JSX.Element | null { 29 | const { getAudioInstance, songData, staticMix, onAudioPause, onAudioPlay } = this.props; 30 | if (!songData && !staticMix) { 31 | return null; 32 | } 33 | 34 | // Show a colour-coded badge indicating the components that are included 35 | let audioTitleExtra; 36 | let audioList: ReactJkMusicPlayerAudioListProps[] = []; 37 | if (songData) { 38 | audioTitleExtra = ; 39 | audioList = [ 40 | { 41 | name: songData.title, 42 | singer: songData.artist, 43 | musicSrc: songData.url, 44 | }, 45 | ]; 46 | } else if (staticMix) { 47 | const separatorBadge = ( 48 | // Remove everything in brackets to save space 49 | {separatorLabelMap[staticMix.separator].replace(/ *\([^)]*\) */g, '')} 50 | ); 51 | const vocalBadge = staticMix.vocals ? : null; 52 | const accompBadge = staticMix.other ? : null; 53 | const bassBadge = staticMix.bass ? : null; 54 | const drumsBadge = staticMix.drums ? : null; 55 | const pianoBadge = staticMix.piano ? : null; 56 | 57 | audioTitleExtra = ( 58 |
59 | {separatorBadge} {vocalBadge} {accompBadge} {bassBadge} {drumsBadge} {pianoBadge} 60 |
61 | ); 62 | audioList = [ 63 | { 64 | name: staticMix.title, 65 | singer: staticMix.artist, 66 | musicSrc: staticMix.url, 67 | }, 68 | ]; 69 | } 70 | 71 | return ( 72 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 73 | //@ts-ignore 74 | 99 | ); 100 | } 101 | } 102 | 103 | export default MusicPlayer; 104 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/CancelButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | disabled: boolean; 6 | onClick: () => void; 7 | } 8 | 9 | /** 10 | * Component for the cancel dynamic mix task button. 11 | */ 12 | class CancelButton extends React.Component { 13 | render(): JSX.Element { 14 | return ( 15 | 18 | ); 19 | } 20 | } 21 | 22 | export default CancelButton; 23 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/CancelTaskModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Modal } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | isCancelling: boolean; 6 | show: boolean; 7 | submit: () => Promise; 8 | hide: () => void; 9 | } 10 | 11 | /** 12 | * Component for the cancel dynamic mix modal. 13 | */ 14 | class CancelTaskModal extends React.Component { 15 | constructor(props: Props) { 16 | super(props); 17 | } 18 | 19 | submit = async (): Promise => { 20 | await this.props.submit(); 21 | this.props.hide(); 22 | }; 23 | 24 | render(): JSX.Element | null { 25 | const { isCancelling } = this.props; 26 | return ( 27 | 28 | 29 | Confirm cancellation 30 | 31 | 32 |
Are you sure you want to cancel this task?
33 |
34 | 35 | 38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default CancelTaskModal; 45 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | className?: string; 6 | disabled: boolean; 7 | onClick: () => void; 8 | } 9 | 10 | /** 11 | * Component for the delete dynamic mix button. 12 | */ 13 | class DeleteButton extends React.Component { 14 | render(): JSX.Element { 15 | return ( 16 | 24 | ); 25 | } 26 | } 27 | 28 | export default DeleteButton; 29 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/DeleteTaskModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Modal } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | isDeleting: boolean; 6 | show: boolean; 7 | submit: () => Promise; 8 | hide: () => void; 9 | } 10 | 11 | /** 12 | * Component for the delete dynamic mix modal. 13 | */ 14 | class DeleteTaskModal extends React.Component { 15 | constructor(props: Props) { 16 | super(props); 17 | } 18 | 19 | submit = async (): Promise => { 20 | await this.props.submit(); 21 | this.props.hide(); 22 | }; 23 | 24 | render(): JSX.Element | null { 25 | const { isDeleting } = this.props; 26 | return ( 27 | 28 | 29 | Confirm deletion 30 | 31 | 32 |
Are you sure you want to delete this mix?
33 |
34 | 35 | 38 | 39 |
40 | ); 41 | } 42 | } 43 | 44 | export default DeleteTaskModal; 45 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/ExportButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, OverlayTrigger, Spinner, Tooltip } from 'react-bootstrap'; 3 | import { BoxArrowUpRight } from 'react-bootstrap-icons'; 4 | import { OverlayInjectedProps } from 'react-bootstrap/esm/Overlay'; 5 | 6 | interface Props { 7 | className?: string; 8 | disabled: boolean; 9 | error?: string; 10 | loading: boolean; 11 | onClick: () => void; 12 | } 13 | 14 | /** 15 | * Component for the export button. 16 | */ 17 | class ExportButton extends React.Component { 18 | render(): JSX.Element { 19 | let content = null; 20 | if (this.props.loading) { 21 | content = ; 22 | } else { 23 | content = ( 24 | <> 25 | 26 | Export 27 | 28 | ); 29 | } 30 | 31 | const renderTooltip = (props: OverlayInjectedProps) => ( 32 | 33 | {this.props.error} 34 | 35 | ); 36 | 37 | const disabledStyle: React.CSSProperties = { 38 | cursor: 'not-allowed', 39 | opacity: '50%', 40 | pointerEvents: 'none', 41 | }; 42 | 43 | let button = ( 44 | 52 | ); 53 | 54 | if (this.props.error) { 55 | button = ( 56 | 57 |
{button}
58 |
59 | ); 60 | } 61 | 62 | return button; 63 | } 64 | } 65 | 66 | export default ExportButton; 67 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/ExportForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Col, Form, InputGroup } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | defaultName: string; 6 | mixName: string; 7 | handleChange: (e: React.ChangeEvent) => void; 8 | } 9 | 10 | /** 11 | * Dynamic mix export form. 12 | */ 13 | class ExportForm extends React.Component { 14 | constructor(props: Props) { 15 | super(props); 16 | } 17 | 18 | render(): JSX.Element { 19 | const { defaultName, handleChange } = this.props; 20 | return ( 21 |
22 | 23 | 24 | Mix name: 25 | 26 | 27 | 28 |
29 | ); 30 | } 31 | } 32 | 33 | export default ExportForm; 34 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/ExportModal.css: -------------------------------------------------------------------------------- 1 | @-webkit-keyframes export-progress-bar-stripes { 2 | 0% { 3 | background-position-x: 1rem; 4 | } 5 | } 6 | 7 | @keyframes export-progress-bar-stripes { 8 | 0% { 9 | background-position-x: 1rem; 10 | } 11 | } 12 | 13 | .export-progress { 14 | display: flex; 15 | height: 1rem; 16 | overflow: hidden; 17 | font-size: 0.75rem; 18 | background-color: #e9ecef; 19 | border-radius: 0.25rem; 20 | } 21 | 22 | .export-progress-bar { 23 | display: flex; 24 | flex-direction: column; 25 | justify-content: center; 26 | overflow: hidden; 27 | color: #fff; 28 | text-align: center; 29 | white-space: nowrap; 30 | background-color: #0d6efd; 31 | transition: width 0.6s ease; 32 | } 33 | @media (prefers-reduced-motion: reduce) { 34 | .export-progress-bar { 35 | transition: none; 36 | } 37 | } 38 | 39 | .export-progress-bar-striped { 40 | background-image: linear-gradient(45deg, rgba(255, 255, 255, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.15) 75%, transparent 75%, transparent); 41 | background-size: 1rem 1rem; 42 | } 43 | 44 | .export-progress-bar-animated { 45 | -webkit-animation: 1s linear infinite export-progress-bar-stripes; 46 | animation: 1s linear infinite export-progress-bar-stripes; 47 | } 48 | @media (prefers-reduced-motion: reduce) { 49 | .export-progress-bar-animated { 50 | -webkit-animation: none; 51 | animation: none; 52 | } 53 | } -------------------------------------------------------------------------------- /frontend/src/components/Mixer/ExportModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Button, Modal, ProgressBar } from 'react-bootstrap'; 3 | import ExportForm from './ExportForm'; 4 | import './ExportModal.css'; 5 | 6 | interface Props { 7 | defaultName: string; 8 | show: boolean; 9 | hide: () => void; 10 | submit: (mixName: string) => Promise; 11 | isExporting: boolean; 12 | exportRatio: number; 13 | } 14 | 15 | interface State { 16 | mixName: string; 17 | } 18 | 19 | /** 20 | * Component for the export modal. 21 | */ 22 | class ExportModal extends React.Component { 23 | constructor(props: Props) { 24 | super(props); 25 | this.state = { 26 | mixName: props.defaultName, 27 | }; 28 | } 29 | 30 | submit = async (): Promise => { 31 | await this.props.submit(this.state.mixName); 32 | }; 33 | 34 | handleMixNameChange = (e: React.ChangeEvent): void => { 35 | const name = e.currentTarget.value; 36 | this.setState({ 37 | mixName: name && name !== '' ? name : this.props.defaultName, 38 | }); 39 | e.stopPropagation(); 40 | }; 41 | 42 | render(): JSX.Element | null { 43 | const { defaultName, isExporting, exportRatio } = this.props; 44 | const exportPct = Math.round(exportRatio * 100); 45 | 46 | return ( 47 | 48 | 49 | Export mix 50 | 51 | 52 | 53 | This exports a custom mix using the current volume levels set for each part. 54 | 55 | 56 | 5 ? `${exportPct}%` : ''} 61 | animated={isExporting} 62 | min={0} 63 | max={100} 64 | /> 65 | 66 | 67 | 70 | 71 | 72 | ); 73 | } 74 | } 75 | 76 | export default ExportModal; 77 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/MixerPlayer.css: -------------------------------------------------------------------------------- 1 | kbd { 2 | color: #000 !important; 3 | display: inline-block; 4 | border: 1px solid #ccc; 5 | padding: 0em .2em !important; 6 | margin: 0 .1em !important; 7 | box-shadow: 0 1px 0 rgb(0 0 0 / 20%), inset 0 0 0 2px #fff; 8 | background-color: #ffffff !important; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/MuteButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { VolumeMuteFill, VolumeUpFill } from 'react-bootstrap-icons'; 4 | 5 | interface Props { 6 | className?: string; 7 | isMuted: boolean; 8 | disabled: boolean; 9 | onClick: () => void; 10 | } 11 | 12 | /** 13 | * Mute button component. 14 | */ 15 | const MuteButton = (props: Props): JSX.Element => { 16 | const { isMuted } = props; 17 | const variant = isMuted ? 'white' : 'secondary'; 18 | 19 | return ( 20 | 30 | ); 31 | }; 32 | 33 | export default MuteButton; 34 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/PlayButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Spinner } from 'react-bootstrap'; 3 | import { PauseFill, PlayFill } from 'react-bootstrap-icons'; 4 | 5 | interface Props { 6 | disabled: boolean; 7 | isPlaying: boolean; 8 | onClick: () => void; 9 | } 10 | 11 | const PlayButton = (props: Props): JSX.Element => { 12 | let content = null; 13 | if (props.disabled) { 14 | content = ; 15 | } else if (props.isPlaying) { 16 | content = ; 17 | } else { 18 | content = ; 19 | } 20 | 21 | return ( 22 | 31 | ); 32 | }; 33 | 34 | export default PlayButton; 35 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/PlayerUI.css: -------------------------------------------------------------------------------- 1 | .time-slider { 2 | width: 100%; 3 | max-width: 500px; 4 | height: 50px; 5 | border-width: 1px; 6 | border-image: initial; 7 | } 8 | 9 | .time-slider .player-thumb { 10 | top: 10px; 11 | width: 10px; 12 | height: 30px; 13 | } 14 | 15 | .player-thumb { 16 | font-size: 1.0em; 17 | text-align: center; 18 | background-color: grey; 19 | color: grey; 20 | cursor: grab; 21 | } 22 | 23 | .player-track { 24 | position: relative; 25 | background: rgb(221, 221, 221); 26 | cursor: pointer; 27 | } 28 | 29 | .time-slider .player-track { 30 | top: 20px; 31 | height: 10px; 32 | border-radius: 5px; 33 | } 34 | 35 | .player-track.player-track-0 { 36 | background: #007bff; 37 | } 38 | 39 | .player-ui { 40 | margin-top: 40px; 41 | margin-bottom: 40px; 42 | display: flex; 43 | align-items: center; 44 | justify-content: center; 45 | } 46 | 47 | .time-elapsed { 48 | margin-left: 10px; 49 | margin-right: 10px; 50 | padding: 5px; 51 | width: 50px; 52 | width: 50px; 53 | } 54 | 55 | .time-duration { 56 | margin-left: 10px; 57 | margin-right: 10px; 58 | padding: 5px; 59 | width: 50px; 60 | width: 50px; 61 | } 62 | 63 | .time-slider.disabled, 64 | .time-slider.disabled > .player-track, 65 | .time-slider.disabled > .player-thumb { 66 | cursor: not-allowed; 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/PlayerUI.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import ReactSlider from 'react-slider'; 3 | import { formatTime } from '../../Utils'; 4 | import PlayButton from './PlayButton'; 5 | import './PlayerUI.css'; 6 | import ExportButton from './ExportButton'; 7 | 8 | interface Props { 9 | isExportDisabled: boolean; 10 | isExportInitializing: boolean; 11 | isPlayDisabled: boolean; 12 | isPlaying: boolean; 13 | durationSeconds: number; 14 | exportError?: string; 15 | secondsElapsed: number; 16 | onPlayClick: () => void; 17 | onExportClick: () => void; 18 | onBeforeSeek: (seconds: number | number[] | undefined | null) => void; 19 | onSeeking: (seconds: number | number[] | undefined | null) => void; 20 | onAfterSeek: (seconds: number | number[] | undefined | null) => void; 21 | } 22 | 23 | const PlayerUI = (props: Props): JSX.Element => { 24 | const { 25 | isExportDisabled, 26 | isExportInitializing, 27 | isPlayDisabled, 28 | isPlaying, 29 | exportError, 30 | onExportClick, 31 | onPlayClick, 32 | onBeforeSeek, 33 | onSeeking, 34 | onAfterSeek, 35 | durationSeconds, 36 | secondsElapsed, 37 | } = props; 38 | return ( 39 |
40 | 41 | {formatTime(secondsElapsed)} 42 | 54 | {formatTime(durationSeconds)} 55 | 61 |
62 | ); 63 | }; 64 | 65 | export default PlayerUI; 66 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/SoloButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | className?: string; 6 | isSoloed: boolean; 7 | disabled: boolean; 8 | onClick: (event: React.MouseEvent) => void; 9 | } 10 | 11 | /** 12 | * Solo button component. 13 | */ 14 | const SoloButton = (props: Props): JSX.Element => { 15 | const { isSoloed } = props; 16 | const variant = isSoloed ? 'white' : 'secondary'; 17 | 18 | return ( 19 | 30 | ); 31 | }; 32 | 33 | export default SoloButton; 34 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/VolumeUI.css: -------------------------------------------------------------------------------- 1 | .vol-slider { 2 | margin-left: 10px; 3 | width: 100%; 4 | max-width: 500px; 5 | height: 50px; 6 | border-width: 1px; 7 | border-image: initial; 8 | } 9 | 10 | .vol-slider .vol-thumb { 11 | top: 10px; 12 | width: 30px; 13 | height: 30px; 14 | } 15 | 16 | .vol-thumb { 17 | font-size: 1em; 18 | text-align: center; 19 | background-color: grey; 20 | color: grey; 21 | border-radius: 50%; 22 | cursor: grab; 23 | } 24 | 25 | .vol-track { 26 | position: relative; 27 | background: rgb(221, 221, 221); 28 | cursor: pointer; 29 | } 30 | 31 | .vol-slider .vol-track { 32 | top: 20px; 33 | height: 10px; 34 | border-radius: 5px; 35 | } 36 | 37 | .vol-track.vol-track-0 { 38 | background: #6c757d; 39 | } 40 | 41 | .vol-label { 42 | font-size: 0.9em; 43 | line-height: 2.1; 44 | color: white; 45 | } 46 | 47 | .volume-ui { 48 | margin-top: 10px; 49 | align-items: center; 50 | justify-content: center; 51 | } 52 | 53 | .vol-badge { 54 | margin-right: 10px; 55 | width: 65px; 56 | } 57 | 58 | .badge-group { 59 | display: flex; 60 | align-items: center; 61 | justify-content: flex-start; 62 | } 63 | 64 | .vol-slider.disabled, .vol-slider.disabled>.vol-track, .vol-slider.disabled>.vol-thumb { 65 | cursor: not-allowed; 66 | } 67 | 68 | .track-inactive { 69 | opacity: 0.5; 70 | } 71 | 72 | .btn-white { 73 | border-color: #999999 !important; 74 | } 75 | -------------------------------------------------------------------------------- /frontend/src/components/Mixer/VolumeUI.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Col, Row } from 'react-bootstrap'; 3 | import { Download } from 'react-bootstrap-icons'; 4 | import ReactSlider from 'react-slider'; 5 | import { PartId } from '../../models/PartId'; 6 | import { AccompShortBadge, BassBadge, DrumsBadge, PianoBadge, VocalsBadge } from '../Badges'; 7 | import MuteButton from './MuteButton'; 8 | import SoloButton from './SoloButton'; 9 | import './VolumeUI.css'; 10 | 11 | interface Props { 12 | id: PartId; 13 | url?: string; 14 | disabled: boolean; 15 | isActive: boolean; 16 | isMuted: boolean; 17 | isSoloed: boolean; 18 | onMuteClick: (id: PartId) => void; 19 | onSoloClick: (id: PartId, overwrite: boolean) => void; 20 | onVolChange: (id: PartId, newVal: number) => void; 21 | } 22 | 23 | /** 24 | * Component for volume slider with mute/solo/download buttons. 25 | */ 26 | const VolumeUI = (props: Props): JSX.Element => { 27 | const onMuteClick = () => { 28 | props.onMuteClick(props.id); 29 | }; 30 | 31 | const onSoloClick = (event: React.MouseEvent) => { 32 | props.onSoloClick(props.id, !event.ctrlKey && !event.metaKey && !event.shiftKey); 33 | }; 34 | 35 | const onVolChange = (value: number | number[] | undefined | null): void => { 36 | if (typeof value === 'number') { 37 | props.onVolChange(props.id, value); 38 | } 39 | }; 40 | 41 | let badge = null; 42 | if (props.id === 'vocals') { 43 | badge = ; 44 | } else if (props.id === 'accomp') { 45 | badge = ; 46 | } else if (props.id === 'bass') { 47 | badge = ; 48 | } else if (props.id === 'drums') { 49 | badge = ; 50 | } else if (props.id === 'piano') { 51 | badge = ; 52 | } 53 | 54 | const trackInactive = props.isActive ? '' : 'track-inactive'; 55 | return ( 56 | 57 |
{badge}
58 | 64 | 70 | 71 | ( 81 |
82 | {state.valueNow} 83 |
84 | )} 85 | /> 86 | 87 | 90 |
91 | ); 92 | }; 93 | 94 | export default VolumeUI; 95 | -------------------------------------------------------------------------------- /frontend/src/components/Nav/HomeNavBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button, Container, Navbar, Nav } from 'react-bootstrap'; 3 | import { CloudUpload } from 'react-bootstrap-icons'; 4 | import { withRouter, RouteComponentProps } from 'react-router'; 5 | 6 | interface Props extends RouteComponentProps { 7 | onUploadClick: () => void; 8 | } 9 | 10 | /** 11 | * Navigation bar with upload button. 12 | */ 13 | const HomeNavBar = (props: Props): JSX.Element => { 14 | return ( 15 | 16 | 17 | Spleeter Web 18 | 19 | 20 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | 31 | export default withRouter(HomeNavBar); 32 | -------------------------------------------------------------------------------- /frontend/src/components/Nav/PlainNavBar.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Container, Navbar } from 'react-bootstrap'; 3 | import { withRouter } from 'react-router'; 4 | 5 | /** 6 | * Plain navigation bar. 7 | */ 8 | const PlainNavBar = (): JSX.Element => { 9 | return ( 10 | 11 | 12 | Spleeter Web 13 | 14 | 15 | ); 16 | }; 17 | 18 | export default withRouter(PlainNavBar); 19 | -------------------------------------------------------------------------------- /frontend/src/components/NotFound.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import PlainNavBar from './Nav/PlainNavBar'; 3 | 4 | class NotFound extends React.Component { 5 | render(): JSX.Element { 6 | return ( 7 |
8 | 9 |
10 |
11 |

Not Found

12 |
13 |
14 |
15 | ); 16 | } 17 | } 18 | 19 | export default NotFound; 20 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/DeleteDynamicMixButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { Trash } from 'react-bootstrap-icons'; 4 | import { DynamicMix } from '../../../models/DynamicMix'; 5 | 6 | interface Props { 7 | disabled?: boolean; 8 | mix: DynamicMix; 9 | onClick: (song: DynamicMix) => void; 10 | } 11 | 12 | /** 13 | * Delete dynamic mix button component. 14 | */ 15 | class DeleteDynamicMixButton extends React.Component { 16 | handleClick = (): void => { 17 | this.props.onClick(this.props.mix); 18 | }; 19 | 20 | render(): JSX.Element { 21 | return ( 22 | 31 | ); 32 | } 33 | } 34 | 35 | export default DeleteDynamicMixButton; 36 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/DeleteStaticMixButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { Trash } from 'react-bootstrap-icons'; 4 | import { StaticMix } from '../../../models/StaticMix'; 5 | 6 | interface Props { 7 | disabled?: boolean; 8 | mix: StaticMix; 9 | onClick: (song: StaticMix) => void; 10 | } 11 | 12 | /** 13 | * Delete static mix button component. 14 | */ 15 | class DeleteStaticMixButton extends React.Component { 16 | handleClick = (): void => { 17 | this.props.onClick(this.props.mix); 18 | }; 19 | 20 | render(): JSX.Element { 21 | return ( 22 | 31 | ); 32 | } 33 | } 34 | 35 | export default DeleteStaticMixButton; 36 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/DeleteTrackButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { Trash } from 'react-bootstrap-icons'; 4 | import { SongData } from '../../../models/SongData'; 5 | 6 | interface Props { 7 | disabled?: boolean; 8 | song: SongData; 9 | onClick: (song: SongData) => void; 10 | } 11 | 12 | /** 13 | * Delete track button component. 14 | */ 15 | class DeleteTrackButton extends React.Component { 16 | handleClick = (): void => { 17 | this.props.onClick(this.props.song); 18 | }; 19 | 20 | render(): JSX.Element { 21 | return ( 22 | 31 | ); 32 | } 33 | } 34 | 35 | export default DeleteTrackButton; 36 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/PausePlayButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { PauseFill, PlayFill } from 'react-bootstrap-icons'; 4 | import { SongData } from '../../../models/SongData'; 5 | import { StaticMix } from '../../../models/StaticMix'; 6 | 7 | interface Props { 8 | song: SongData | StaticMix; 9 | disabled: boolean; 10 | disabledText: string; 11 | playing: boolean; 12 | onPauseClick: (song: SongData | StaticMix) => void; 13 | onPlayClick: (song: SongData | StaticMix) => void; 14 | } 15 | 16 | /** 17 | * Component for pause/play button shown in the song table. 18 | */ 19 | class PausePlayButton extends React.Component { 20 | handlePlay = (): void => { 21 | if (this.props.playing) { 22 | this.props.onPauseClick(this.props.song); 23 | } else { 24 | this.props.onPlayClick(this.props.song); 25 | } 26 | }; 27 | 28 | render(): JSX.Element { 29 | const { playing, disabled } = this.props; 30 | const customButton = ( 31 | 45 | ); 46 | 47 | return customButton; 48 | } 49 | } 50 | 51 | export default PausePlayButton; 52 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/PlayMixButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { RecordPlayer } from './RecordPlayer'; 4 | 5 | interface Props { 6 | mixId: string; 7 | } 8 | 9 | /** 10 | * Component for the 'play mix' button shown in the MixTable. 11 | */ 12 | class PlayMixButton extends React.Component { 13 | render(): JSX.Element { 14 | const { mixId } = this.props; 15 | 16 | return ( 17 | 25 | ); 26 | } 27 | } 28 | 29 | export default PlayMixButton; 30 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/RecordPlayer.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/ban-ts-comment */ 2 | // @ts-nocheck 3 | import React from 'react'; 4 | 5 | interface Props { 6 | width: number; 7 | height: number; 8 | } 9 | 10 | // Icon made from Icon Fonts (onlinewebfonts.com/icon/497039) is licensed by CC BY 3.0. 11 | export const RecordPlayer = (props: Props): JSX.Element => { 12 | return ( 13 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/TextButton.css: -------------------------------------------------------------------------------- 1 | .text-btn { 2 | white-space: nowrap; 3 | } 4 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Button/TextButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Button } from 'react-bootstrap'; 3 | import { SongData } from '../../../models/SongData'; 4 | import './TextButton.css'; 5 | 6 | interface Props { 7 | className: string; 8 | variant?: string; 9 | disabled: boolean; 10 | song: SongData; 11 | onClick: (song: SongData) => void; 12 | } 13 | 14 | class TextButton extends React.Component { 15 | handleClick = (): void => { 16 | this.props.onClick(this.props.song); 17 | }; 18 | 19 | render(): JSX.Element { 20 | return ( 21 | 28 | ); 29 | } 30 | } 31 | 32 | export default TextButton; 33 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Form/DynamicMixModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Form } from 'react-bootstrap'; 3 | import { SongData } from '../../../models/SongData'; 4 | import SeparatorFormGroup from './SeparatorFormGroup'; 5 | import SongInfoFormGroup from './SongInfoFormGroup'; 6 | import './MixModalForm.css'; 7 | 8 | interface Props { 9 | song: SongData; 10 | handleModelChange: (newModel: string) => void; 11 | handleRandomShiftsChange: (newRandomShifts: number) => void; 12 | handleIterationsChange: (newIterations: number) => void; 13 | handleSoftmaskChange: (newSoftmaskChecked: boolean) => void; 14 | handleAlphaChange: (newAlpha: number) => void; 15 | handleOutputFormatChange: (newOutputFormatChange: number) => void; 16 | } 17 | 18 | /** 19 | * Source separation form portion of the modal. 20 | */ 21 | class DynamicMixModalForm extends React.Component { 22 | render(): JSX.Element { 23 | const { 24 | song, 25 | handleModelChange, 26 | handleRandomShiftsChange, 27 | handleIterationsChange, 28 | handleSoftmaskChange, 29 | handleAlphaChange, 30 | handleOutputFormatChange, 31 | } = this.props; 32 | 33 | return ( 34 |
35 | 36 | 45 | 46 | ); 47 | } 48 | } 49 | 50 | export default DynamicMixModalForm; 51 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Form/MixModalForm.css: -------------------------------------------------------------------------------- 1 | .capitalize { 2 | text-transform: capitalize; 3 | } 4 | 5 | #random-shifts { 6 | display: inline-flex; 7 | align-items: center; 8 | } 9 | 10 | #random-shifts-question { 11 | cursor: pointer; 12 | } 13 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Form/SongInfoFormGroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Col, Form, Row } from 'react-bootstrap'; 3 | import { SongData } from '../../../models/SongData'; 4 | 5 | interface Props { 6 | song: SongData; 7 | } 8 | 9 | const SongInfoFormGroup = (props: Props): JSX.Element => { 10 | const song = props.song; 11 | 12 | return ( 13 | <> 14 | 15 | 16 | Title: 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Artist: 25 | 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default SongInfoFormGroup; 35 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Form/StaticMixModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Form } from 'react-bootstrap'; 3 | import { MusicPartMap4, MusicPartMap5 } from '../../../models/MusicParts'; 4 | import { SongData } from '../../../models/SongData'; 5 | import SeparatorFormGroup from './SeparatorFormGroup'; 6 | import SongInfoFormGroup from './SongInfoFormGroup'; 7 | import './MixModalForm.css'; 8 | 9 | interface Props { 10 | song: SongData; 11 | handleCheckboxChange: (event: React.ChangeEvent) => void; 12 | handleModelChange: (newModel: string) => void; 13 | handleRandomShiftsChange: (newRandomShifts: number) => void; 14 | handleIterationsChange: (newIterations: number) => void; 15 | handleSoftmaskChange: (newSoftmaskChecked: boolean) => void; 16 | handleAlphaChange: (newAlpha: number) => void; 17 | handleOutputFormatChange: (newOutputFormat: number) => void; 18 | } 19 | 20 | interface State { 21 | /** 22 | * Selected separation model. 23 | */ 24 | selectedModel: string; 25 | } 26 | 27 | /** 28 | * Source separation form portion of the modal. 29 | */ 30 | class StaticMixModalForm extends React.Component { 31 | constructor(props: Props) { 32 | super(props); 33 | this.state = { 34 | selectedModel: 'spleeter', 35 | }; 36 | } 37 | 38 | handleModelChange = (newModel: string): void => { 39 | this.setState({ 40 | selectedModel: newModel, 41 | }); 42 | this.props.handleModelChange(newModel); 43 | }; 44 | 45 | render(): JSX.Element { 46 | const { 47 | song, 48 | handleCheckboxChange, 49 | handleRandomShiftsChange, 50 | handleIterationsChange, 51 | handleSoftmaskChange, 52 | handleAlphaChange, 53 | handleOutputFormatChange, 54 | } = this.props; 55 | const MusicPartMap = this.state.selectedModel === 'spleeter_5stems' ? MusicPartMap5 : MusicPartMap4; 56 | 57 | // Map part names to checkboxes 58 | const checkboxes = Array.from(MusicPartMap.keys()).map((key: string): JSX.Element => { 59 | return ( 60 | 61 | 68 | 69 | ); 70 | }); 71 | 72 | return ( 73 |
74 | 75 | 84 | 85 | Parts to keep: 86 |
{checkboxes}
87 |
88 | 89 | ); 90 | } 91 | } 92 | 93 | export default StaticMixModalForm; 94 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Form/XUMXFormSubgroup.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Col, Form } from 'react-bootstrap'; 3 | import { MAX_SOFTMASK_ALPHA, MIN_SOFTMASK_ALPHA } from '../../../Constants'; 4 | 5 | interface Props { 6 | alpha: string; 7 | softmask: boolean; 8 | onAlphaChange: (event: React.ChangeEvent) => void; 9 | onAlphaFocusOut: (event: React.FocusEvent) => void; 10 | onSoftmaskChange: (event: React.ChangeEvent) => void; 11 | } 12 | 13 | class XUMXFormSubgroup extends React.Component { 14 | constructor(props: Props) { 15 | super(props); 16 | } 17 | 18 | render(): JSX.Element { 19 | const { alpha, onAlphaChange, onAlphaFocusOut, onSoftmaskChange, softmask } = this.props; 20 | 21 | return ( 22 | 23 | 24 | Softmask: 25 | 32 | 33 | {softmask && ( 34 | 35 | Softmask alpha: 36 | 46 | 47 | )} 48 | 49 | ); 50 | } 51 | } 52 | 53 | export default XUMXFormSubgroup; 54 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/MixTable.css: -------------------------------------------------------------------------------- 1 | .table > tbody > tr > td { 2 | vertical-align: middle; 3 | } 4 | 5 | .react-bootstrap-table .row-expansion-style { 6 | padding: 0; 7 | } 8 | 9 | .react-bootstrap-table table { 10 | table-layout: auto !important; 11 | } 12 | 13 | .reset-expansion-style { 14 | background-color: unset !important; 15 | } 16 | 17 | .inner-table-div { 18 | padding-left: 2.3rem !important; 19 | } 20 | 21 | .inner-table { 22 | background-color: #ececec; 23 | } 24 | 25 | .inner-table th { 26 | border-top: #b7b7b7 solid 2px !important; 27 | border-bottom: #b7b7b7 solid 2px !important; 28 | } 29 | 30 | .inner-table td { 31 | border-top: #b7b7b7 solid 2px !important; 32 | } 33 | 34 | .inner-table > tbody > tr > td { 35 | background-color: #e8e8e8 !important; 36 | } 37 | 38 | .btn-secondary.disabled, 39 | .btn-secondary:disabled { 40 | color: #fff; 41 | background-color: #b1b2b3 !important; 42 | border-color: #b1b2b3 !important; 43 | } 44 | 45 | .spinner-border { 46 | vertical-align: middle !important; 47 | -webkit-animation: spinner-border 1s linear infinite !important; 48 | animation: spinner-border 1s linear infinite !important; 49 | } 50 | 51 | .reset-expansion-style { 52 | background-color: #ececec; 53 | } 54 | 55 | .badge { 56 | margin-right: 5px; 57 | } 58 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Modal/DeleteDynamicMixModal.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as React from 'react'; 3 | import { Alert, Button, Modal } from 'react-bootstrap'; 4 | import { DynamicMix } from '../../../models/DynamicMix'; 5 | 6 | interface Props { 7 | mix?: DynamicMix; 8 | show: boolean; 9 | exit: () => void; 10 | hide: () => void; 11 | refresh: () => void; 12 | } 13 | 14 | interface State { 15 | isDeleting: boolean; 16 | errors: string[]; 17 | } 18 | 19 | /** 20 | * Component for the delete dynamic mix modal. 21 | */ 22 | class DeleteDynamicMixModal extends React.Component { 23 | constructor(props: Props) { 24 | super(props); 25 | this.state = { 26 | isDeleting: false, 27 | errors: [], 28 | }; 29 | } 30 | 31 | /** 32 | * Reset errors 33 | */ 34 | resetErrors = (): void => { 35 | this.setState({ 36 | isDeleting: false, 37 | errors: [], 38 | }); 39 | }; 40 | 41 | /** 42 | * Called when modal hidden without finishing 43 | */ 44 | onHide = (): void => { 45 | this.props.hide(); 46 | }; 47 | 48 | /** 49 | * Called when modal finishes exit animation 50 | */ 51 | onExited = (): void => { 52 | this.resetErrors(); 53 | this.props.exit(); 54 | }; 55 | 56 | /** 57 | * Called when primary modal button is clicked 58 | */ 59 | onSubmit = (): void => { 60 | if (!this.props.mix) { 61 | return; 62 | } 63 | 64 | // DELETE request to delete the mix 65 | const mixId = this.props.mix.id; 66 | console.log(mixId); 67 | 68 | this.setState({ 69 | isDeleting: true, 70 | }); 71 | 72 | axios 73 | .delete(`/api/mix/dynamic/${mixId}/`) 74 | .then(() => { 75 | this.props.refresh(); 76 | this.props.hide(); 77 | this.setState({ 78 | isDeleting: false, 79 | }); 80 | }) 81 | .catch(({ response }) => { 82 | const { data } = response; 83 | this.setState({ 84 | isDeleting: false, 85 | errors: [data.error], 86 | }); 87 | }); 88 | }; 89 | 90 | render(): JSX.Element | null { 91 | const { isDeleting, errors } = this.state; 92 | const { show, mix } = this.props; 93 | if (!mix) { 94 | return null; 95 | } 96 | 97 | return ( 98 | 99 | 100 | Confirm dynamic mix deletion 101 | 102 | 103 | {errors.length > 0 && ( 104 | 105 | {errors.map((val, idx) => ( 106 |
{val}
107 | ))} 108 |
109 | )} 110 |
111 | Are you sure you want to delete this dynamic mix of “{mix.artist} - {mix.title}” ( 112 | {mix.separator})? 113 |
114 |
115 | 116 | 119 | 122 | 123 |
124 | ); 125 | } 126 | } 127 | 128 | export default DeleteDynamicMixModal; 129 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Modal/DeleteStaticMixModal.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as React from 'react'; 3 | import { Alert, Button, Modal } from 'react-bootstrap'; 4 | import { StaticMix } from '../../../models/StaticMix'; 5 | 6 | interface Props { 7 | mix?: StaticMix; 8 | show: boolean; 9 | exit: () => void; 10 | hide: () => void; 11 | refresh: () => void; 12 | } 13 | 14 | interface State { 15 | isDeleting: boolean; 16 | errors: string[]; 17 | } 18 | 19 | /** 20 | * Component for the delete static mix modal. 21 | */ 22 | class DeleteStaticMixModal extends React.Component { 23 | constructor(props: Props) { 24 | super(props); 25 | this.state = { 26 | isDeleting: false, 27 | errors: [], 28 | }; 29 | } 30 | 31 | /** 32 | * Reset errors 33 | */ 34 | resetErrors = (): void => { 35 | this.setState({ 36 | isDeleting: false, 37 | errors: [], 38 | }); 39 | }; 40 | 41 | /** 42 | * Called when modal hidden without finishing 43 | */ 44 | onHide = (): void => { 45 | this.props.hide(); 46 | }; 47 | 48 | /** 49 | * Called when modal finishes exit animation 50 | */ 51 | onExited = (): void => { 52 | this.resetErrors(); 53 | this.props.exit(); 54 | }; 55 | 56 | /** 57 | * Called when primary modal button is clicked 58 | */ 59 | onSubmit = (): void => { 60 | if (!this.props.mix) { 61 | return; 62 | } 63 | 64 | // DELETE request to delete the source track. 65 | const mixId = this.props.mix.id; 66 | console.log(mixId); 67 | 68 | this.setState({ 69 | isDeleting: true, 70 | }); 71 | 72 | axios 73 | .delete(`/api/mix/static/${mixId}/`) 74 | .then(() => { 75 | this.props.refresh(); 76 | this.props.hide(); 77 | this.setState({ 78 | isDeleting: false, 79 | }); 80 | }) 81 | .catch(({ response }) => { 82 | const { data } = response; 83 | this.setState({ 84 | isDeleting: false, 85 | errors: [data.error], 86 | }); 87 | }); 88 | }; 89 | 90 | render(): JSX.Element | null { 91 | const { isDeleting, errors } = this.state; 92 | const { show, mix } = this.props; 93 | if (!mix) { 94 | return null; 95 | } 96 | 97 | const parts: string[] = []; 98 | if (mix.vocals) { 99 | parts.push('vocals'); 100 | } 101 | if (mix.other) { 102 | parts.push('accompaniment'); 103 | } 104 | if (mix.piano) { 105 | parts.push('piano'); 106 | } 107 | if (mix.bass) { 108 | parts.push('bass'); 109 | } 110 | if (mix.drums) { 111 | parts.push('drums'); 112 | } 113 | 114 | const description = parts.join(', '); 115 | 116 | return ( 117 | 118 | 119 | Confirm static mix deletion 120 | 121 | 122 | {errors.length > 0 && ( 123 | 124 | {errors.map((val, idx) => ( 125 |
{val}
126 | ))} 127 |
128 | )} 129 |
130 | Are you sure you want to delete the static mix “{mix.artist} - {mix.title}” with {description}? 131 |
132 |
133 | 134 | 137 | 140 | 141 |
142 | ); 143 | } 144 | } 145 | 146 | export default DeleteStaticMixModal; 147 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/Modal/DeleteTrackModal.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Alert, Button, Modal } from 'react-bootstrap'; 3 | import axios from 'axios'; 4 | import { SongData } from '../../../models/SongData'; 5 | 6 | interface Props { 7 | song?: SongData; 8 | show: boolean; 9 | exit: () => void; 10 | hide: () => void; 11 | refresh: () => void; 12 | } 13 | 14 | interface State { 15 | isDeleting: boolean; 16 | errors: string[]; 17 | } 18 | 19 | /** 20 | * Component for the delete track modal. 21 | */ 22 | class DeleteTrackModal extends React.Component { 23 | constructor(props: Props) { 24 | super(props); 25 | this.state = { 26 | isDeleting: false, 27 | errors: [], 28 | }; 29 | } 30 | 31 | /** 32 | * Reset errors 33 | */ 34 | resetErrors = (): void => { 35 | this.setState({ 36 | isDeleting: false, 37 | errors: [], 38 | }); 39 | }; 40 | 41 | /** 42 | * Called when modal hidden without finishing 43 | */ 44 | onHide = (): void => { 45 | this.props.hide(); 46 | }; 47 | 48 | /** 49 | * Called when modal finishes exit animation 50 | */ 51 | onExited = (): void => { 52 | this.resetErrors(); 53 | this.props.exit(); 54 | }; 55 | 56 | /** 57 | * Called when primary modal button is clicked 58 | */ 59 | onSubmit = (): void => { 60 | if (!this.props.song) { 61 | return; 62 | } 63 | 64 | this.setState({ 65 | isDeleting: true, 66 | }); 67 | 68 | // DELETE request to delete the source track. 69 | const songId = this.props.song.id; 70 | axios 71 | .delete(`/api/source-track/${songId}/`) 72 | .then(() => { 73 | this.props.refresh(); 74 | this.props.hide(); 75 | this.setState({ 76 | isDeleting: false, 77 | }); 78 | }) 79 | .catch(({ response }) => { 80 | const { data } = response; 81 | this.setState({ 82 | isDeleting: false, 83 | errors: [data.error], 84 | }); 85 | }); 86 | }; 87 | 88 | render(): JSX.Element | null { 89 | const { isDeleting, errors } = this.state; 90 | const { show, song } = this.props; 91 | if (!song) { 92 | return null; 93 | } 94 | 95 | return ( 96 | 97 | 98 | Confirm track deletion 99 | 100 | 101 | {errors.length > 0 && ( 102 | 103 | {errors.map((val, idx) => ( 104 |
{val}
105 | ))} 106 |
107 | )} 108 |
109 | Are you sure you want to delete “{song.artist} - {song.title}” and all of its mixes? 110 |
111 |
112 | 113 | 116 | 119 | 120 |
121 | ); 122 | } 123 | } 124 | 125 | export default DeleteTrackModal; 126 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/SongTable.css: -------------------------------------------------------------------------------- 1 | .table > tbody > tr > td { 2 | vertical-align: middle; 3 | } 4 | 5 | th.expand-cell-header { 6 | padding: 0 !important; 7 | text-align: center !important; 8 | vertical-align: middle !important; 9 | cursor: pointer !important; 10 | } 11 | 12 | td.expand-cell { 13 | cursor: pointer; 14 | padding: 0 !important; 15 | text-align: center !important; 16 | } 17 | 18 | .react-bootstrap-table .row-expansion-style { 19 | padding: 0; 20 | } 21 | 22 | .react-bootstrap-table table { 23 | table-layout: auto !important; 24 | } 25 | 26 | .row-expand-slide-appear-active { 27 | transition: max-height 0.5s ease-in !important; 28 | } 29 | 30 | .row-expand-slide-exit-active { 31 | transition: 0 0.25s ease-out !important; 32 | } 33 | 34 | .inner-table > tbody > tr:nth-child(odd) > td, 35 | .inner-table > tbody > tr:nth-child(even) > td { 36 | background-color: white; 37 | } 38 | 39 | .react-bootstrap-table th[data-row-selection] { 40 | width: 16px; 41 | } 42 | 43 | .btn-light { 44 | border-color: #676767 !important; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/components/SongTable/StatusIcon.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CheckCircleFill, ClockFill, XCircleFill } from 'react-bootstrap-icons'; 3 | import { Overlay, Tooltip } from 'react-bootstrap'; 4 | import { TaskStatus } from '../../models/TaskStatus'; 5 | 6 | const StatusToComponent = ( 7 | status: TaskStatus, 8 | ref: React.MutableRefObject, 9 | onMouseOver: () => void, 10 | onMouseOut: () => void 11 | ) => { 12 | switch (status) { 13 | case 'Queued': 14 | return ; 15 | case 'In Progress': 16 | return ; 17 | case 'Error': 18 | return ; 19 | default: 20 | return ; 21 | } 22 | }; 23 | 24 | interface Props { 25 | status: TaskStatus | null; 26 | overlayText?: string; 27 | } 28 | 29 | export const StatusIcon = ({ status, overlayText }: Props): JSX.Element => { 30 | const [show, setShow] = React.useState(false); 31 | const target = React.useRef(null); 32 | 33 | const statusComponent = StatusToComponent( 34 | status ?? 'Done', 35 | target, 36 | () => setShow(true), 37 | () => setShow(false) 38 | ); 39 | 40 | return ( 41 | <> 42 | {statusComponent} 43 | 44 | {props => ( 45 | 46 | {overlayText || (status ?? 'Done')} 47 | 48 | )} 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default StatusIcon; 55 | -------------------------------------------------------------------------------- /frontend/src/components/Upload/CustomInput.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { IInputProps } from '@jeffreyca/react-dropzone-uploader'; 3 | import { getDroppedOrSelectedFiles } from 'html5-file-selector'; 4 | import * as React from 'react'; 5 | import { OverlayTrigger, Tooltip } from 'react-bootstrap'; 6 | import { CloudUpload, InfoCircle } from 'react-bootstrap-icons'; 7 | import { OverlayInjectedProps } from 'react-bootstrap/esm/Overlay'; 8 | import { ALLOWED_EXTENSIONS } from '../../Constants'; 9 | 10 | /** 11 | * Custom file input component for the dropzone uploader. 12 | */ 13 | const CustomInput = ({ accept, onFiles, files, disabled }: IInputProps): JSX.Element | null => { 14 | const text = 'Select file'; 15 | const buttonClass = disabled ? 'btn btn-primary disabled' : 'btn btn-primary'; 16 | /** 17 | * Get dropped files. 18 | */ 19 | const getFilesFromEvent = (e: any) => { 20 | return new Promise(resolve => { 21 | getDroppedOrSelectedFiles(e).then((chosenFiles: any) => { 22 | resolve(chosenFiles.map((f: any) => f.fileObject)); 23 | }); 24 | }); 25 | }; 26 | 27 | const supportedFormatsTooltip = (props: OverlayInjectedProps) => { 28 | const text = 'Supported: ' + ALLOWED_EXTENSIONS.sort().join(', '); 29 | 30 | return ( 31 | 32 | {text} 33 | 34 | ); 35 | }; 36 | 37 | return files.length > 0 ? null : ( 38 |
39 | 40 |

41 | Drag and drop an audio file. {' '} 42 | 43 | 44 | 45 |

46 | 47 | 60 |
61 | ); 62 | }; 63 | 64 | export default CustomInput; 65 | -------------------------------------------------------------------------------- /frontend/src/components/Upload/CustomPreview.tsx: -------------------------------------------------------------------------------- 1 | import { formatBytes, formatDuration, IPreviewProps } from '@jeffreyca/react-dropzone-uploader'; 2 | import * as React from 'react'; 3 | import { ProgressBar } from 'react-bootstrap'; 4 | import cancelImg from '../../svg/cancel.svg'; 5 | import removeImg from '../../svg/remove.svg'; 6 | import restartImg from '../../svg/restart.svg'; 7 | 8 | const iconByFn = { 9 | cancel: { backgroundImage: `url(${cancelImg})` }, 10 | remove: { backgroundImage: `url(${removeImg})` }, 11 | restart: { backgroundImage: `url(${restartImg})` }, 12 | }; 13 | 14 | /** 15 | * Custom file preview component for the dropzone uploader (shown after files are selected) 16 | */ 17 | const CustomPreview = ({ 18 | className, 19 | imageClassName, 20 | style, 21 | imageStyle, 22 | fileWithMeta: { cancel, remove, restart }, 23 | meta: { name = '', percent = 0, size = 0, previewUrl, status, duration, validationError }, 24 | isUpload, 25 | canCancel, 26 | canRemove, 27 | canRestart, 28 | extra: { minSizeBytes }, 29 | }: IPreviewProps): JSX.Element => { 30 | const cancelThenRemove = () => { 31 | cancel(); 32 | remove(); 33 | }; 34 | let title = `${name || '?'}, ${formatBytes(size)}`; 35 | if (duration) { 36 | title = `${title}, ${formatDuration(duration)}`; 37 | } 38 | 39 | if (status === 'error_file_size' || status === 'error_validation') { 40 | return ( 41 |
42 |
43 |
44 | {title} 45 |
46 |
47 |
48 |
49 |
50 | {status === 'error_file_size' && ( 51 | {size < minSizeBytes ? 'File too small!' : 'File too large!'} 52 | )} 53 | {status === 'error_validation' && {String(validationError)}} 54 | {canRemove && ( 55 | 56 | )} 57 |
58 |
59 |
60 |
61 | ); 62 | } 63 | 64 | let variant = 'primary'; 65 | if (status === 'error_upload_params' || status === 'exception_upload' || status === 'error_upload') { 66 | title = `${title} (upload failed)`; 67 | variant = 'danger'; 68 | } 69 | 70 | if (status === 'aborted') { 71 | title = `${title} (cancelled)`; 72 | variant = 'secondary'; 73 | } 74 | 75 | const doneUpload = status === 'done' || status === 'headers_received'; 76 | if (doneUpload) { 77 | variant = 'success'; 78 | } 79 | 80 | return ( 81 |
82 |
83 |
84 | {previewUrl && ( 85 | {title} 86 | )} 87 | {!previewUrl && {title}} 88 |
89 |
90 |
91 |
92 |
93 | {isUpload && ( 94 | 101 | )} 102 | {status === 'uploading' && canCancel && ( 103 | 104 | )} 105 | {status !== 'preparing' && status !== 'getting_upload_params' && status !== 'uploading' && canRemove && ( 106 | 107 | )} 108 |
109 |
110 |
111 |
112 | ); 113 | }; 114 | 115 | export default CustomPreview; 116 | -------------------------------------------------------------------------------- /frontend/src/components/Upload/UploadModal.css: -------------------------------------------------------------------------------- 1 | .btn.disabled { 2 | pointer-events: none; 3 | } 4 | 5 | .btn.disabled, 6 | .btn[disabled], 7 | fieldset[disabled] .btn { 8 | cursor: not-allowed; 9 | } 10 | 11 | .dzu-dropzone { 12 | display: flex; 13 | justify-content: center; 14 | align-items: center; 15 | flex-direction: column; 16 | width: 100%; 17 | min-height: 120px; 18 | margin: 0 auto; 19 | position: relative; 20 | box-sizing: border-box; 21 | transition: all 0.15s linear; 22 | border: 2px solid #d9d9d9; 23 | border-radius: 4px; 24 | } 25 | 26 | .dzu-dropzoneActive { 27 | background-color: #deebff; 28 | border-color: #2484ff; 29 | } 30 | 31 | .dzu-dropzoneDisabled { 32 | opacity: 0.5; 33 | } 34 | 35 | .dzu-dropzoneDisabled *:hover { 36 | cursor: unset; 37 | } 38 | 39 | .dzu-input { 40 | display: none; 41 | } 42 | 43 | .dzu-inputLabel { 44 | font-size: 18px; 45 | font-weight: 300; 46 | cursor: pointer; 47 | } 48 | 49 | .dzu-inputLabelWithFiles { 50 | display: flex; 51 | justify-content: center; 52 | align-items: center; 53 | align-self: flex-start; 54 | padding: 0 14px; 55 | min-height: 32px; 56 | background-color: #e6e6e6; 57 | color: #2484ff; 58 | border: none; 59 | font-family: 'Helvetica', sans-serif; 60 | border-radius: 4px; 61 | font-size: 14px; 62 | font-weight: 600; 63 | margin-top: 20px; 64 | margin-left: 3%; 65 | -moz-osx-font-smoothing: grayscale; 66 | -webkit-font-smoothing: antialiased; 67 | cursor: pointer; 68 | } 69 | 70 | .dzu-sizeError { 71 | color: #ff0000; 72 | } 73 | 74 | .dzu-previewContainer { 75 | padding: 40px 3%; 76 | width: 100%; 77 | min-height: 60px; 78 | z-index: 1; 79 | border-bottom: 1px solid #ececec; 80 | box-sizing: border-box; 81 | } 82 | 83 | .dzu-previewStatusContainer { 84 | display: flex; 85 | align-items: center; 86 | } 87 | 88 | .dzu-previewFileName { 89 | font-family: 'Helvetica', sans-serif; 90 | font-size: 14px; 91 | font-weight: 400; 92 | color: #333333; 93 | } 94 | 95 | .dzu-previewImage { 96 | width: auto; 97 | max-height: 40px; 98 | max-width: 140px; 99 | border-radius: 4px; 100 | } 101 | 102 | .dzu-previewButton { 103 | background-size: 14px 14px; 104 | background-position: center; 105 | background-repeat: no-repeat; 106 | width: 14px; 107 | height: 14px; 108 | cursor: pointer; 109 | opacity: 0.9; 110 | margin: 0 0 2px 10px; 111 | } 112 | 113 | .dzu-submitButtonContainer { 114 | margin: 24px 0; 115 | z-index: 1; 116 | } 117 | 118 | .dzu-submitButton { 119 | padding: 0 14px; 120 | min-height: 32px; 121 | background-color: #2484ff; 122 | border: none; 123 | border-radius: 4px; 124 | font-family: 'Helvetica', sans-serif; 125 | font-size: 14px; 126 | font-weight: 600; 127 | color: #fff; 128 | -moz-osx-font-smoothing: grayscale; 129 | -webkit-font-smoothing: antialiased; 130 | cursor: pointer; 131 | } 132 | 133 | .dzu-submitButton:disabled { 134 | background-color: #e6e6e6; 135 | color: #333333; 136 | cursor: unset; 137 | } 138 | 139 | .hr-text { 140 | line-height: 1em; 141 | position: relative; 142 | outline: 0; 143 | border: 0; 144 | color: black; 145 | text-align: center; 146 | height: 1.5em; 147 | } 148 | 149 | .hr-text:before { 150 | content: ''; 151 | background: linear-gradient(to right, transparent, #818078, transparent); 152 | position: absolute; 153 | left: 0; 154 | top: 50%; 155 | width: 100%; 156 | height: 1px; 157 | } 158 | 159 | .hr-text:after { 160 | content: attr(data-content); 161 | position: relative; 162 | display: inline-block; 163 | color: black; 164 | padding: 0 0.5em; 165 | line-height: 1.5em; 166 | color: #818078; 167 | background-color: #fcfcfa; 168 | } 169 | 170 | .input-group>.custom-select:not(:last-child), .input-group>.form-control:not(:last-child) { 171 | border-top-right-radius: 0.25rem !important; 172 | border-bottom-right-radius: 0.25rem !important; 173 | } 174 | -------------------------------------------------------------------------------- /frontend/src/components/Upload/UploadModalForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Col, Form } from 'react-bootstrap'; 3 | 4 | interface Props { 5 | artist: string; 6 | title: string; 7 | handleChange: (e: React.ChangeEvent) => void; 8 | } 9 | 10 | /** 11 | * Upload form portion of the modal. 12 | */ 13 | class UploadModalForm extends React.Component { 14 | constructor(props: Props) { 15 | super(props); 16 | } 17 | 18 | render(): JSX.Element { 19 | const { artist, title, handleChange } = this.props; 20 | return ( 21 | 22 | 23 | Artist 24 | 25 | 26 | 27 | 28 | Title 29 | 30 | 31 | 32 | ); 33 | } 34 | } 35 | 36 | export default UploadModalForm; 37 | -------------------------------------------------------------------------------- /frontend/src/components/Upload/YouTubeForm.css: -------------------------------------------------------------------------------- 1 | .form-control::-webkit-input-placeholder { 2 | color: rgb(175, 175, 175) !important; 3 | } 4 | .form-control:-moz-placeholder { 5 | color: rgb(175, 175, 175) !important; 6 | } 7 | .form-control::-moz-placeholder { 8 | color: rgb(175, 175, 175) !important; 9 | } 10 | .form-control:-ms-input-placeholder { 11 | color: rgb(175, 175, 175) !important; 12 | } 13 | .form-control::-ms-input-placeholder { 14 | color: rgb(175, 175, 175) !important; 15 | } 16 | 17 | .yt-search-result { 18 | white-space: nowrap; 19 | overflow: hidden; 20 | text-overflow: ellipsis; 21 | align-self: center; 22 | } 23 | 24 | .yt-search-extlink { 25 | align-self: center; 26 | } 27 | 28 | .yt-search-title { 29 | font-weight: bold; 30 | } 31 | 32 | .yt-search-channel { 33 | font-size: smaller; 34 | } 35 | 36 | .yt-search-duration { 37 | font-size: smaller; 38 | color: grey; 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/components/Upload/YouTubeForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Col, Form, InputGroup, Spinner } from 'react-bootstrap'; 3 | import { Check, X } from 'react-bootstrap-icons'; 4 | import { YouTubeLinkFetchStatus } from '../../models/YouTubeLinkFetchStatus'; 5 | import { YouTubeSearchResponse } from '../../models/YouTubeSearchResponse'; 6 | import { YouTubeVideo } from '../../models/YouTubeVideo'; 7 | import './YouTubeForm.css'; 8 | import { YouTubeSearchResultList } from './YouTubeSearchResultList'; 9 | 10 | interface Props { 11 | value?: string; 12 | searchResponse?: YouTubeSearchResponse; 13 | disabled: boolean; 14 | fetchStatus: YouTubeLinkFetchStatus; 15 | handleChange: (event: React.ChangeEvent) => void; 16 | onSearchResultClick: (video: YouTubeVideo) => void; 17 | } 18 | 19 | export const YouTubeForm = (props: Props): JSX.Element | null => { 20 | const { value, searchResponse: searchResults, disabled, fetchStatus, handleChange, onSearchResultClick } = props; 21 | let trailingIcon = null; 22 | if (fetchStatus === YouTubeLinkFetchStatus.IS_FETCHING) { 23 | trailingIcon =