diff --git a/.circleci/config.yml b/.circleci/config.yml index b769876..4cae4ca 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,40 +1,207 @@ version: 2 jobs: - test: + # ======================================================================================= + # Python 3.6 testing. + # ======================================================================================= + test-3.6: + docker: + - image: python:3.6 + working_directory: ~/spleeter + steps: + - checkout + - restore_cache: + key: models-{{ checksum "spleeter/model/__init__.py" }} + - run: + name: install ffmpeg + command: apt-get update && apt-get install -y ffmpeg + - run: + name: install python dependencies + command: pip install -r requirements.txt && pip install pytest pytest-xdist + - run: + name: run tests + command: make test + - save_cache: + key: models-{{ checksum "spleeter/model/__init__.py" }} + paths: + - "pretrained_models" + # ======================================================================================= + # Python 3.7 testing. + # ======================================================================================= + test-3.7: docker: - image: python:3.7 working_directory: ~/spleeter steps: - checkout + - restore_cache: + key: models-{{ checksum "spleeter/model/__init__.py" }} - run: name: install ffmpeg command: apt-get update && apt-get install -y ffmpeg - run: - name: install spleeter - command: pip install . + name: install python dependencies + command: pip install -r requirements.txt && pip install pytest pytest-xdist - run: - name: test separation - command: spleeter separate -i audio_example.mp3 -o . - upload: + name: run tests + command: make test + - save_cache: + key: models-{{ checksum "spleeter/model/__init__.py" }} + paths: + - "pretrained_models" + # ======================================================================================= + # Source distribution packaging. + # ======================================================================================= + sdist: docker: - image: python:3 steps: - checkout - run: - name: package - command: python setup.py sdist + name: package source distribution + command: make build + - save_cache: + key: sdist-{{ .Branch }}-{{ checksum "setup.py" }} + paths: + - dist + # ======================================================================================= + # PyPi deployment. + # ======================================================================================= + pypi-deploy: + docker: + - image: python:3 + steps: + - checkout + - restore_cache: + key: sdist-{{ .Branch }}-{{ checksum "setup.py" }} - run: name: upload to PyPi - command: pip install twine && twine upload dist/* + # TODO: Infer destination regarding of branch. + # - master => production PyPi + # - other => testing PyPi + command: make deploy + # ======================================================================================= + # Conda distribution. + # ======================================================================================= + conda-forge-deploy: + docker: + - image: python:3 + steps: + - run: + name: install dependencies + command: apt-get update && apt-get install -y git openssl hub + - run: + name: checkout feedstock + command: make feedstock + # ======================================================================================= + # Docker build. + # ======================================================================================= + docker-conda-cpu: + docker: + - image: docker:17.05.0-ce-git + steps: + - checkout + - run: docker build -t researchdeezer/spleeter:conda -f docker/cpu/conda.dockerfile . + - run: docker build -t researchdeezer/spleeter:conda-2stems -f docker/cpu/conda-2stems.dockerfile . + - run: docker build -t researchdeezer/spleeter:conda-4stems -f docker/cpu/conda-2stems.dockerfile . + - run: docker build -t researchdeezer/spleeter:conda-5stems -f docker/cpu/conda-2stems.dockerfile . + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:conda separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:conda-2stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:conda-4stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:conda-5stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD + - run: docker push researchdeezer/spleeter:conda + - run: docker push researchdeezer/spleeter:conda-2stems + - run: docker push researchdeezer/spleeter:conda-4stems + - run: docker push researchdeezer/spleeter:conda-5stems + docker-3.6-cpu: + docker: + - image: docker:17.05.0-ce-git + steps: + - checkout + - run: docker build -t researchdeezer/spleeter:3.6 -f docker/cpu/python-3.6.dockerfile . + - run: docker build -t researchdeezer/spleeter:3.6-2stems -f docker/cpu/python-3.6-2stems.dockerfile . + - run: docker build -t researchdeezer/spleeter:3.6-4stems -f docker/cpu/python-3.6-4stems.dockerfile . + - run: docker build -t researchdeezer/spleeter:3.6-5stems -f docker/cpu/python-3.6-5stems.dockerfile . + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.6 separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.6-2stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.6-4stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.6-5stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD + - run: docker push researchdeezer/spleeter:3.6 + - run: docker push researchdeezer/spleeter:3.6-2stems + - run: docker push researchdeezer/spleeter:3.6-4stems + - run: docker push researchdeezer/spleeter:3.6-5stems + docker-3.7-cpu: + docker: + - image: docker:17.05.0-ce-git + steps: + - checkout + - run: docker build -t spleeter:3.7 -f docker/cpu/python-3.7.dockerfile . + - run: docker build -t spleeter:3.7-2stems -f docker/cpu/python-3.7-2stems.dockerfile . + - run: docker build -t spleeter:3.7-4stems -f docker/cpu/python-3.7-4stems.dockerfile . + - run: docker build -t spleeter:3.7-5stems -f docker/cpu/python-3.7-5stems.dockerfile . + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.7 separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.7-2stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.7-4stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker run -v $(pwd):/runtime researchdeezer/spleeter:3.7-5stems separate -i /runtime/audio_example.mp3 -o /tmp + - run: docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD + - run: docker tags researchdeezer/spleeter:3.7 researchdeezer/spleeter:latest + - run: docker push researchdeezer/spleeter:latest + - run: docker push researchdeezer/spleeter:3.7 + - run: docker push researchdeezer/spleeter:3.7-2stems + - run: docker push researchdeezer/spleeter:3.7-4stems + - run: docker push researchdeezer/spleeter:3.7-5stems workflows: version: 2 - test-and-deploy: + spleeter-release-pipeline: jobs: - - test - - upload: + - test-3.6 + - test-3.7 + - sdist: + requires: + - test-3.6 + - test-3.7 + - pypi-deploy: filters: branches: only: - master requires: - - test \ No newline at end of file + - sdist + - conda-forge-deploy: + filters: + branches: + only: + - master + requires: + - pypi-deploy + - hold: + type: approval + requires: + - pypi-deploy + - conda-forge-deploy + filters: + branches: + only: + - master + - docker-conda-cpu: + requires: + - hold + filters: + branches: + only: + - master + - docker-3.6-cpu: + requires: + - hold + filters: + branches: + only: + - master + - docker-3.7-cpu: + requires: + - hold + filters: + branches: + only: + - master \ No newline at end of file diff --git a/.gitignore b/.gitignore index 29d7036..3660fc0 100644 --- a/.gitignore +++ b/.gitignore @@ -109,4 +109,5 @@ __pycache__ pretrained_models docs/build -.vscode \ No newline at end of file +.vscode +spleeter-feedstock/ \ No newline at end of file diff --git a/Makefile b/Makefile index 989f5ce..6bde98d 100644 --- a/Makefile +++ b/Makefile @@ -1,30 +1,40 @@ # ======================================================= -# Build script for distribution packaging. +# Library lifecycle management. # # @author Deezer Research # @licence MIT Licence # ======================================================= +FFEDSTOCK = spleeter-feedstock +FEEDSTOCK_REPOSITORY = https://github.com/deezer/$(FEEDSTOCK) +FEEDSTOCK_RECIPE = $(FEEDSTOCK)/recipe/spleeter/meta.yaml + +all: clean build test deploy + clean: rm -Rf *.egg-info rm -Rf dist build: - @echo "=== Build CPU bdist package" - @python3 setup.py sdist - @echo "=== CPU version checksum" - @openssl sha256 dist/*.tar.gz + python3 setup.py sdist -build-gpu: - @echo "=== Build GPU bdist package" - @python3 setup.py sdist --target gpu - @echo "=== GPU version checksum" - @openssl sha256 dist/*.tar.gz +test: + pytest -W ignore::FutureWarning -W ignore::DeprecationWarning -vv --forked -upload: - twine upload dist/* +feedstock: build + $(eval VERSION = $(shell grep 'project_version = ' setup.py | cut -d' ' -f3 | sed "s/'//g")) + $(eval CHECKSUM = $(shell openssl sha256 dist/spleeter-$(VERSION).tar.gz | cut -d' ' -f2)) + git clone $(FEEDSTOCK_REPOSITORY) + sed 's/{% set version = "[0-9]*\.[0-9]*\.[0-9]*" %}/{% set version = "$(VERSION)" %}/g' $(FEEDSTOCK_RECIPE) + sed 's/sha256: [0-9a-z]*/sha: $(CHECKSUM)/g' $(FEEDSTOCK_RECIPE) + git config credential.helper 'cache --timeout=120' + git config user.email "research@deezer.com" + git config user.name "spleeter-ci" + git add recipe/spleeter/meta.yaml + git commit --allow-empty -m "feat: update spleeter version from CI" + git push -q https://$$FEEDSTOCK_TOKEN@github.com/deezer/$(FEEDSTOCK) + hub pull-request -m "Update spleeter version to $(VERSION)" -test-upload: - twine upload --repository-url https://test.pypi.org/legacy/ dist/* - -all: clean build build-gpu upload \ No newline at end of file +deploy: pip-dependencies + pip install twine + twine upload dist/* \ No newline at end of file diff --git a/README.md b/README.md index 84494d7..fde7987 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ -[![CircleCI](https://circleci.com/gh/deezer/spleeter/tree/master.svg?style=shield)](https://circleci.com/gh/deezer/spleeter/tree/master) [![PyPI version](https://badge.fury.io/py/spleeter.svg)](https://badge.fury.io/py/spleeter) [![Conda](https://img.shields.io/conda/vn/conda-forge/spleeter)](https://anaconda.org/conda-forge/spleeter) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/spleeter) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/deezer/spleeter/blob/master/spleeter.ipynb) +[![CircleCI](https://circleci.com/gh/deezer/spleeter/tree/master.svg?style=shield)](https://circleci.com/gh/deezer/spleeter/tree/master) [![PyPI version](https://badge.fury.io/py/spleeter.svg)](https://badge.fury.io/py/spleeter) [![Conda](https://img.shields.io/conda/vn/conda-forge/spleeter)](https://anaconda.org/conda-forge/spleeter) ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/spleeter) [![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/deezer/spleeter/blob/master/spleeter.ipynb) [![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/spleeter/community) + + ## About @@ -28,8 +30,7 @@ environment to start separating audio file as follows: ```bash git clone https://github.com/Deezer/spleeter -conda env create -f spleeter/conda/spleeter-cpu.yaml -conda activate spleeter-cpu +conda install -c conda-forge spleeter spleeter separate -i spleeter/audio_example.mp3 -p spleeter:2stems -o output ``` You should get two separated audio files (`vocals.wav` and `accompaniment.wav`) diff --git a/docker/cpu.Dockerfile b/docker/cpu.Dockerfile deleted file mode 100644 index e3f47e7..0000000 --- a/docker/cpu.Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM continuumio/miniconda3:4.7.10 - -# install tensorflow -RUN conda install -y tensorflow==1.14.0 - -# install ffmpeg for audio loading/writing -RUN conda install -y -c conda-forge ffmpeg - -# install extra python libraries -RUN conda install -y -c anaconda pandas==0.25.1 -RUN conda install -y -c conda-forge libsndfile - -# install ipython -RUN conda install -y ipython - -WORKDIR /workspace/ -COPY ./ spleeter/ - -RUN mkdir /cache/ - -WORKDIR /workspace/spleeter -RUN pip install . - -ENTRYPOINT ["python", "-m", "spleeter"] \ No newline at end of file diff --git a/docker/cpu/conda-2-stems.dockerfile b/docker/cpu/conda-2stems.dockerfile similarity index 52% rename from docker/cpu/conda-2-stems.dockerfile rename to docker/cpu/conda-2stems.dockerfile index bf7e33a..0c4bbf0 100644 --- a/docker/cpu/conda-2-stems.dockerfile +++ b/docker/cpu/conda-2stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:conda +FROM researchdeezer/spleeter:conda RUN mkdir -p /model/2stems \ && wget -O /tmp/2stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/2stems.tar.gz \ - && tar -xvzf /tmp/2stems.tar.gz -C /model/2stems/ + && tar -xvzf /tmp/2stems.tar.gz -C /model/2stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/conda-4-stems.dockerfile b/docker/cpu/conda-4stems.dockerfile similarity index 52% rename from docker/cpu/conda-4-stems.dockerfile rename to docker/cpu/conda-4stems.dockerfile index f91fdf4..b32c84c 100644 --- a/docker/cpu/conda-4-stems.dockerfile +++ b/docker/cpu/conda-4stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:conda +FROM researchdeezer/spleeter:conda RUN mkdir -p /model/4stems \ && wget -O /tmp/4stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/4stems.tar.gz \ - && tar -xvzf /tmp/4stems.tar.gz -C /model/4stems/ + && tar -xvzf /tmp/4stems.tar.gz -C /model/4stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/conda-5-stems.dockerfile b/docker/cpu/conda-5stems.dockerfile similarity index 52% rename from docker/cpu/conda-5-stems.dockerfile rename to docker/cpu/conda-5stems.dockerfile index 2fac5dd..6b6f2ca 100644 --- a/docker/cpu/conda-5-stems.dockerfile +++ b/docker/cpu/conda-5stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:conda +FROM researchdeezer/spleeter:conda RUN mkdir -p /model/5stems \ && wget -O /tmp/5stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/5stems.tar.gz \ - && tar -xvzf /tmp/5stems.tar.gz -C /model/5stems/ + && tar -xvzf /tmp/5stems.tar.gz -C /model/5stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/conda.dockerfile b/docker/cpu/conda.dockerfile index a9b9d45..1506a68 100644 --- a/docker/cpu/conda.dockerfile +++ b/docker/cpu/conda.dockerfile @@ -1,12 +1,9 @@ FROM continuumio/miniconda3:4.7.10 -RUN conda install -y ipython \ - && conda install -y tensorflow==1.14.0 \ - && conda install -y -c conda-forge ffmpeg \ - && conda install -y -c conda-forge libsndfile \ - && conda install -y -c anaconda pandas==0.25.1 \ +RUN conda install -y -c conda-forge musdb +# RUN conda install -y -c conda-forge museval +RUN conda install -y -c conda-forge spleeter=1.4.4 RUN mkdir -p /model ENV MODEL_PATH /model -RUN pip install spleeter ENTRYPOINT ["spleeter"] \ No newline at end of file diff --git a/docker/cpu/python-3.6-2stems.dockerfile b/docker/cpu/python-3.6-2stems.dockerfile index b43e785..3fca665 100644 --- a/docker/cpu/python-3.6-2stems.dockerfile +++ b/docker/cpu/python-3.6-2stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:3.6 +FROM researchdeezer/spleeter:3.6 RUN mkdir -p /model/2stems \ && wget -O /tmp/2stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/2stems.tar.gz \ - && tar -xvzf /tmp/2stems.tar.gz -C /model/2stems/ + && tar -xvzf /tmp/2stems.tar.gz -C /model/2stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/python-3.6-4stems.dockerfile b/docker/cpu/python-3.6-4stems.dockerfile index 9708880..d19171e 100644 --- a/docker/cpu/python-3.6-4stems.dockerfile +++ b/docker/cpu/python-3.6-4stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:3.6 +FROM researchdeezer/spleeter:3.6 RUN mkdir -p /model/4stems \ && wget -O /tmp/4stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/4stems.tar.gz \ - && tar -xvzf /tmp/4stems.tar.gz -C /model/4stems/ + && tar -xvzf /tmp/4stems.tar.gz -C /model/4stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/python-3.6-5stems.dockerfile b/docker/cpu/python-3.6-5stems.dockerfile index 57f3b90..a2a14ce 100644 --- a/docker/cpu/python-3.6-5stems.dockerfile +++ b/docker/cpu/python-3.6-5stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:3.6 +FROM researchdeezer/spleeter:3.6 RUN mkdir -p /model/5stems \ && wget -O /tmp/5stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/5stems.tar.gz \ - && tar -xvzf /tmp/5stems.tar.gz -C /model/5stems/ + && tar -xvzf /tmp/5stems.tar.gz -C /model/5stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/python-3.6.dockerfile b/docker/cpu/python-3.6.dockerfile index e0ec580..3561198 100644 --- a/docker/cpu/python-3.6.dockerfile +++ b/docker/cpu/python-3.6.dockerfile @@ -1,7 +1,8 @@ FROM python:3.6 -RUN apt-get update && apt-get install -y ffmpeg libsndfile -RUN pip install spleeter +RUN apt-get update && apt-get install -y ffmpeg libsndfile1 +RUN pip install musdb museval +RUN pip install spleeter==1.4.4 RUN mkdir -p /model ENV MODEL_PATH /model ENTRYPOINT ["spleeter"] \ No newline at end of file diff --git a/docker/cpu/python-3.7-2stems.dockerfile b/docker/cpu/python-3.7-2stems.dockerfile index 7e9f2ad..c3232a3 100644 --- a/docker/cpu/python-3.7-2stems.dockerfile +++ b/docker/cpu/python-3.7-2stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:3.7 +FROM researchdeezer/spleeter:3.7 RUN mkdir -p /model/2stems \ && wget -O /tmp/2stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/2stems.tar.gz \ - && tar -xvzf /tmp/2stems.tar.gz -C /model/2stems/ + && tar -xvzf /tmp/2stems.tar.gz -C /model/2stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/python-3.7-4stems.dockerfile b/docker/cpu/python-3.7-4stems.dockerfile index 1ccb911..dfa2366 100644 --- a/docker/cpu/python-3.7-4stems.dockerfile +++ b/docker/cpu/python-3.7-4stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:3.7 +FROM researchdeezer/spleeter:3.7 RUN mkdir -p /model/4stems \ && wget -O /tmp/4stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/4stems.tar.gz \ - && tar -xvzf /tmp/4stems.tar.gz -C /model/4stems/ + && tar -xvzf /tmp/4stems.tar.gz -C /model/4stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/python-3.7-5stems.dockerfile b/docker/cpu/python-3.7-5stems.dockerfile index a69611c..0c05955 100644 --- a/docker/cpu/python-3.7-5stems.dockerfile +++ b/docker/cpu/python-3.7-5stems.dockerfile @@ -1,5 +1,6 @@ -FROM deezer/spleeter:3.7 +FROM researchdeezer/spleeter:3.7 RUN mkdir -p /model/5stems \ && wget -O /tmp/5stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/5stems.tar.gz \ - && tar -xvzf /tmp/5stems.tar.gz -C /model/5stems/ + && tar -xvzf /tmp/5stems.tar.gz -C /model/5stems/ \ + && touch /model/5stems/.probe diff --git a/docker/cpu/python-3.7.dockerfile b/docker/cpu/python-3.7.dockerfile index c4f370e..0433e5d 100644 --- a/docker/cpu/python-3.7.dockerfile +++ b/docker/cpu/python-3.7.dockerfile @@ -1,7 +1,8 @@ FROM python:3.7 -RUN apt-get update && apt-get install -y ffmpeg libsndfile -RUN pip install spleeter +RUN apt-get update && apt-get install -y ffmpeg libsndfile1 +RUN pip install musdb museval +RUN pip install spleeter==1.4.4 RUN mkdir -p /model ENV MODEL_PATH /model ENTRYPOINT ["spleeter"] \ No newline at end of file diff --git a/docker/gpu.Dockerfile b/docker/gpu.Dockerfile deleted file mode 100644 index aedeeda..0000000 --- a/docker/gpu.Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -FROM nvidia/cuda:10.1-cudnn7-runtime-ubuntu18.04 - -# set work directory -WORKDIR /workspace - -# install anaconda -ENV PATH /opt/conda/bin:$PATH -COPY docker/install_miniconda.sh . -RUN bash ./install_miniconda.sh && rm install_miniconda.sh - -RUN conda update -n base -c defaults conda - -# install tensorflow for GPU -RUN conda install -y tensorflow-gpu==1.14.0 - -# install ffmpeg for audio loading/writing -RUN conda install -y -c conda-forge ffmpeg - -# install extra libs -RUN conda install -y -c anaconda pandas==0.25.1 -RUN conda install -y -c conda-forge libsndfile - -# install ipython -RUN conda install -y ipython - -RUN mkdir /cache/ - -# clone inside image github repository -COPY ./ spleeter/ - -WORKDIR /workspace/spleeter -RUN pip install . - - -ENTRYPOINT ["python", "-m", "spleeter"] \ No newline at end of file diff --git a/docker/gpu/conda-gpu-2-stems.dockerfile b/docker/gpu/conda-gpu-2-stems.dockerfile index f55b0e1..888b524 100644 --- a/docker/gpu/conda-gpu-2-stems.dockerfile +++ b/docker/gpu/conda-gpu-2-stems.dockerfile @@ -1,4 +1,4 @@ -FROM deezer/spleeter:conda-gpu +FROM researchdeezer/spleeter:conda-gpu RUN mkdir -p /model/2stems \ && wget -O /tmp/2stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/2stems.tar.gz \ diff --git a/docker/gpu/conda-gpu-4-stems.dockerfile b/docker/gpu/conda-gpu-4-stems.dockerfile index 98952d1..3d9c4f7 100644 --- a/docker/gpu/conda-gpu-4-stems.dockerfile +++ b/docker/gpu/conda-gpu-4-stems.dockerfile @@ -1,4 +1,4 @@ -FROM deezer/spleeter:conda-gpu +FROM researchdeezer/spleeter:conda-gpu RUN mkdir -p /model/4stems \ && wget -O /tmp/4stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/4stems.tar.gz \ diff --git a/docker/gpu/conda-gpu-5-stems.dockerfile b/docker/gpu/conda-gpu-5-stems.dockerfile index 0e8135f..5d5ae3b 100644 --- a/docker/gpu/conda-gpu-5-stems.dockerfile +++ b/docker/gpu/conda-gpu-5-stems.dockerfile @@ -1,4 +1,4 @@ -FROM deezer/spleeter:conda-gpu +FROM researchdeezer/spleeter:conda-gpu RUN mkdir -p /model/5stems \ && wget -O /tmp/5stems.tar.gz https://github.com/deezer/spleeter/releases/download/v1.4.0/5stems.tar.gz \ diff --git a/docker/gpu/conda-gpu.dockerfile b/docker/gpu/conda-gpu.dockerfile index 3da59d0..b98693c 100644 --- a/docker/gpu/conda-gpu.dockerfile +++ b/docker/gpu/conda-gpu.dockerfile @@ -1,12 +1,22 @@ -FROM continuumio/miniconda3:4.7.10 +FROM nvidia/cuda:10.1-cudnn7-runtime-ubuntu18.04 -RUN conda install -y ipython \ +RUN apt-get update --fix-missing \ + && apt-get install -y wget bzip2 ca-certificates curl git \ + && apt-get clean && \ + && rm -rf /var/lib/apt/lists/* \ + && wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.6.14-Linux-x86_64.sh -O ~/miniconda.sh \ + && /bin/bash ~/miniconda.sh -b -p /opt/conda \ + && rm ~/miniconda.sh \ + && /opt/conda/bin/conda clean -tipsy \ + && ln -s /opt/conda/etc/profile.d/conda.sh /etc/profile.d/conda.sh \ + && echo ". /opt/conda/etc/profile.d/conda.sh" >> ~/.bashrc \ + && echo "conda activate base" >> ~/.bashrc + +RUN conda install -y cudatoolkit=9.0 \ && conda install -y tensorflow-gpu==1.14.0 \ - && conda install -y -c conda-forge ffmpeg \ - && conda install -y -c conda-forge libsndfile \ - && conda install -y -c anaconda pandas==0.25.1 \ -RUN mkdir -p /model -ENV MODEL_PATH /model -RUN pip install spleeter + && conda install -y -c conda-forge musdb +# RUN conda install -y -c conda-forge museval +# Note: switch to spleeter GPU once published. +RUN conda install -y -c conda-forge spleeter=1.4.4 ENTRYPOINT ["spleeter"] \ No newline at end of file diff --git a/docker/install_miniconda.sh b/docker/install_miniconda.sh deleted file mode 100644 index 6ea58bc..0000000 --- a/docker/install_miniconda.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/bash -apt-get update --fix-missing && \ -apt-get install -y wget bzip2 ca-certificates curl git && \ -apt-get clean && \ -rm -rf /var/lib/apt/lists/* - -wget --quiet https://repo.anaconda.com/miniconda/Miniconda3-4.6.14-Linux-x86_64.sh -O ~/miniconda.sh && \ -/bin/bash ~/miniconda.sh -b -p /opt/conda && \ -rm ~/miniconda.sh && \ -/opt/conda/bin/conda clean -tipsy && \ -ln -s /opt/conda/etc/profile.d/conda.sh /etc/profile.d/conda.sh && \ -echo ". /opt/conda/etc/profile.d/conda.sh" >> ~/.bashrc && \ -echo "conda activate base" >> ~/.bashrc \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..8e1f7be --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +importlib_resources; python_version<'3.7' +requests +setuptools>=41.0.0 +pandas==0.25.1 +tensorflow==1.14.0 +ffmpeg-python +norbert==0.2.1 \ No newline at end of file diff --git a/setup.py b/setup.py index 6680413..c4665e6 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ __license__ = 'MIT License' # Default project values. project_name = 'spleeter' -project_version = '1.4.3' +project_version = '1.4.4' device_target = 'cpu' tensorflow_dependency = 'tensorflow' tensorflow_version = '1.14.0' @@ -51,13 +51,13 @@ setup( license='MIT License', packages=[ 'spleeter', + 'spleeter.audio', 'spleeter.commands', 'spleeter.model', 'spleeter.model.functions', 'spleeter.model.provider', 'spleeter.resources', 'spleeter.utils', - 'spleeter.utils.audio', ], package_data={'spleeter.resources': ['*.json']}, python_requires='>=3.6, <3.8', diff --git a/spleeter/__init__.py b/spleeter/__init__.py index e369371..0650c97 100644 --- a/spleeter/__init__.py +++ b/spleeter/__init__.py @@ -16,3 +16,9 @@ __email__ = 'research@deezer.com' __author__ = 'Deezer Research' __license__ = 'MIT License' + + +class SpleeterError(Exception): + """ Custom exception for Spleeter related error. """ + + pass diff --git a/spleeter/__main__.py b/spleeter/__main__.py index 5f72040..5c18b21 100644 --- a/spleeter/__main__.py +++ b/spleeter/__main__.py @@ -10,9 +10,13 @@ import sys import warnings +from . import SpleeterError from .commands import create_argument_parser from .utils.configuration import load_configuration -from .utils.logging import enable_logging, enable_tensorflow_logging +from .utils.logging import ( + enable_logging, + enable_tensorflow_logging, + get_logger) __email__ = 'research@deezer.com' __author__ = 'Deezer Research' @@ -26,19 +30,22 @@ def main(argv): :param argv: Provided command line arguments. """ - parser = create_argument_parser() - arguments = parser.parse_args(argv[1:]) - enable_logging() - if arguments.verbose: - enable_tensorflow_logging() - if arguments.command == 'separate': - from .commands.separate import entrypoint - elif arguments.command == 'train': - from .commands.train import entrypoint - elif arguments.command == 'evaluate': - from .commands.evaluate import entrypoint - params = load_configuration(arguments.params_filename) - entrypoint(arguments, params) + try: + parser = create_argument_parser() + arguments = parser.parse_args(argv[1:]) + enable_logging() + if arguments.verbose: + enable_tensorflow_logging() + if arguments.command == 'separate': + from .commands.separate import entrypoint + elif arguments.command == 'train': + from .commands.train import entrypoint + elif arguments.command == 'evaluate': + from .commands.evaluate import entrypoint + params = load_configuration(arguments.configuration) + entrypoint(arguments, params) + except SpleeterError as e: + get_logger().error(e) def entrypoint(): diff --git a/spleeter/utils/audio/__init__.py b/spleeter/audio/__init__.py similarity index 100% rename from spleeter/utils/audio/__init__.py rename to spleeter/audio/__init__.py diff --git a/spleeter/utils/audio/adapter.py b/spleeter/audio/adapter.py similarity index 93% rename from spleeter/utils/audio/adapter.py rename to spleeter/audio/adapter.py index d1a64c0..bda1441 100644 --- a/spleeter/utils/audio/adapter.py +++ b/spleeter/audio/adapter.py @@ -16,7 +16,8 @@ import tensorflow as tf from tensorflow.contrib.signal import stft, hann_window # pylint: enable=import-error -from ..logging import get_logger +from .. import SpleeterError +from ..utils.logging import get_logger __email__ = 'research@deezer.com' __author__ = 'Deezer Research' @@ -73,7 +74,8 @@ class AudioAdapter(ABC): # Defined safe loading function. def safe_load(path, offset, duration, sample_rate, dtype): - get_logger().info( + logger = get_logger() + logger.info( f'Loading audio {path} from {offset} to {offset + duration}') try: (data, _) = self.load( @@ -82,10 +84,12 @@ class AudioAdapter(ABC): duration.numpy(), sample_rate.numpy(), dtype=dtype.numpy()) - get_logger().info('Audio data loaded successfully') + logger.info('Audio data loaded successfully') return (data, False) except Exception as e: - get_logger().warning(e) + logger.exception( + 'An error occurs while loading audio', + exc_info=e) return (np.float32(-1.0), True) # Execute function and format results. @@ -140,6 +144,6 @@ def get_audio_adapter(descriptor): adapter_module = import_module(module_path) adapter_class = getattr(adapter_module, adapter_class_name) if not isinstance(adapter_class, AudioAdapter): - raise ValueError( + raise SpleeterError( f'{adapter_class_name} is not a valid AudioAdapter class') return adapter_class() diff --git a/spleeter/utils/audio/convertor.py b/spleeter/audio/convertor.py similarity index 97% rename from spleeter/utils/audio/convertor.py rename to spleeter/audio/convertor.py index b6a7953..550345a 100644 --- a/spleeter/utils/audio/convertor.py +++ b/spleeter/audio/convertor.py @@ -8,7 +8,7 @@ import numpy as np import tensorflow as tf # pylint: enable=import-error -from ..tensor import from_float32_to_uint8, from_uint8_to_float32 +from ..utils.tensor import from_float32_to_uint8, from_uint8_to_float32 __email__ = 'research@deezer.com' __author__ = 'Deezer Research' diff --git a/spleeter/utils/audio/ffmpeg.py b/spleeter/audio/ffmpeg.py similarity index 89% rename from spleeter/utils/audio/ffmpeg.py rename to spleeter/audio/ffmpeg.py index d9c5506..df837b3 100644 --- a/spleeter/utils/audio/ffmpeg.py +++ b/spleeter/audio/ffmpeg.py @@ -16,7 +16,8 @@ import numpy as np # pylint: enable=import-error from .adapter import AudioAdapter -from ..logging import get_logger +from .. import SpleeterError +from ..utils.logging import get_logger __email__ = 'research@deezer.com' __author__ = 'Deezer Research' @@ -54,12 +55,18 @@ class FFMPEGProcessAudioAdapter(AudioAdapter): :param sample_rate: (Optional) Sample rate to load audio with. :param dtype: (Optional) Numpy data type to use, default to float32. :returns: Loaded data a (waveform, sample_rate) tuple. + :raise SpleeterError: If any error occurs while loading audio. """ if not isinstance(path, str): path = path.decode() - probe = ffmpeg.probe(path) + try: + probe = ffmpeg.probe(path) + except ffmpeg._run.Error as e: + raise SpleeterError( + 'An error occurs with ffprobe (see ffprobe output below)\n\n{}' + .format(e.stderr.decode())) if 'streams' not in probe or len(probe['streams']) == 0: - raise IOError('No stream was found with ffprobe') + raise SpleeterError('No stream was found with ffprobe') metadata = next( stream for stream in probe['streams'] @@ -117,5 +124,5 @@ class FFMPEGProcessAudioAdapter(AudioAdapter): process.stdin.close() process.wait() except IOError: - raise IOError(f'FFMPEG error: {process.stderr.read()}') + raise SpleeterError(f'FFMPEG error: {process.stderr.read()}') get_logger().info('File %s written', path) diff --git a/spleeter/utils/audio/spectrogram.py b/spleeter/audio/spectrogram.py similarity index 100% rename from spleeter/utils/audio/spectrogram.py rename to spleeter/audio/spectrogram.py diff --git a/spleeter/commands/__init__.py b/spleeter/commands/__init__.py index 331ee2d..2bbc974 100644 --- a/spleeter/commands/__init__.py +++ b/spleeter/commands/__init__.py @@ -13,67 +13,77 @@ __email__ = 'research@deezer.com' __author__ = 'Deezer Research' __license__ = 'MIT License' -# -i opt specification. +# -i opt specification (separate). OPT_INPUT = { - 'dest': 'audio_filenames', + 'dest': 'inputs', 'nargs': '+', 'help': 'List of input audio filenames', 'required': True } -# -o opt specification. +# -o opt specification (evaluate and separate). OPT_OUTPUT = { 'dest': 'output_path', 'default': join(gettempdir(), 'separated_audio'), 'help': 'Path of the output directory to write audio files in' } -# -p opt specification. +# -f opt specification (separate). +OPT_FORMAT = { + 'dest': 'filename_format', + 'default': '{filename}/{instrument}.{codec}', + 'help': ( + 'Template string that will be formatted to generated' + 'output filename. Such template should be Python formattable' + 'string, and could use {filename}, {instrument}, and {codec}' + 'variables.' + ) +} + +# -p opt specification (train, evaluate and separate). OPT_PARAMS = { - 'dest': 'params_filename', + 'dest': 'configuration', 'default': 'spleeter:2stems', 'type': str, 'action': 'store', 'help': 'JSON filename that contains params' } -# -n opt specification. -OPT_OUTPUT_NAMING = { - 'dest': 'output_naming', - 'default': 'filename', - 'choices': ('directory', 'filename'), - 'help': ( - 'Choice for naming the output base path: ' - '"filename" (use the input filename, i.e ' - '/path/to/audio/mix.wav will be separated to ' - '/mix/.wav, ' - '/mix/.wav...) or ' - '"directory" (use the name of the input last level' - ' directory, for instance /path/to/audio/mix.wav ' - 'will be separated to /audio/.wav' - ', /audio/.wav)') +# -s opt specification (separate). +OPT_OFFSET = { + 'dest': 'offset', + 'type': float, + 'default': 0., + 'help': 'Set the starting offset to separate audio from.' } # -d opt specification (separate). OPT_DURATION = { - 'dest': 'max_duration', + 'dest': 'duration', 'type': float, 'default': 600., 'help': ( 'Set a maximum duration for processing audio ' - '(only separate max_duration first seconds of ' + '(only separate offset + duration first seconds of ' 'the input file)') } -# -c opt specification. +# -c opt specification (separate). OPT_CODEC = { - 'dest': 'audio_codec', + 'dest': 'codec', 'choices': ('wav', 'mp3', 'ogg', 'm4a', 'wma', 'flac'), 'default': 'wav', 'help': 'Audio codec to be used for the separated output' } -# -m opt specification. +# -b opt specification (separate). +OPT_BITRATE = { + 'dest': 'bitrate', + 'default': '128k', + 'help': 'Audio bitrate to be used for the separated output' +} + +# -m opt specification (evaluate and separate). OPT_MWF = { 'dest': 'MWF', 'action': 'store_const', @@ -82,7 +92,7 @@ OPT_MWF = { 'help': 'Whether to use multichannel Wiener filtering for separation', } -# --mus_dir opt specification. +# --mus_dir opt specification (evaluate). OPT_MUSDB = { 'dest': 'mus_dir', 'type': str, @@ -98,14 +108,14 @@ OPT_DATA = { 'help': 'Path of the folder containing audio data for training' } -# -a opt specification. +# -a opt specification (train, evaluate and separate). OPT_ADAPTER = { 'dest': 'audio_adapter', 'type': str, 'help': 'Name of the audio adapter to use for audio I/O' } -# -a opt specification. +# -a opt specification (train, evaluate and separate). OPT_VERBOSE = { 'action': 'store_true', 'help': 'Shows verbose logs' @@ -158,11 +168,13 @@ def _create_separate_parser(parser_factory): """ parser = parser_factory('separate', help='Separate audio files') _add_common_options(parser) - parser.add_argument('-i', '--audio_filenames', **OPT_INPUT) + parser.add_argument('-i', '--inputs', **OPT_INPUT) parser.add_argument('-o', '--output_path', **OPT_OUTPUT) - parser.add_argument('-n', '--output_naming', **OPT_OUTPUT_NAMING) - parser.add_argument('-d', '--max_duration', **OPT_DURATION) - parser.add_argument('-c', '--audio_codec', **OPT_CODEC) + parser.add_argument('-f', '--filename_format', **OPT_FORMAT) + parser.add_argument('-d', '--duration', **OPT_DURATION) + parser.add_argument('-s', '--offset', **OPT_OFFSET) + parser.add_argument('-c', '--codec', **OPT_CODEC) + parser.add_argument('-b', '--birate', **OPT_BITRATE) parser.add_argument('-m', '--mwf', **OPT_MWF) return parser diff --git a/spleeter/commands/evaluate.py b/spleeter/commands/evaluate.py index c2fe789..9bc6d96 100644 --- a/spleeter/commands/evaluate.py +++ b/spleeter/commands/evaluate.py @@ -44,7 +44,6 @@ __license__ = 'MIT License' _SPLIT = 'test' _MIXTURE = 'mixture.wav' -_NAMING = 'directory' _AUDIO_DIRECTORY = 'audio' _METRICS_DIRECTORY = 'metrics' _INSTRUMENTS = ('vocals', 'drums', 'bass', 'other') @@ -71,7 +70,6 @@ def _separate_evaluation_dataset(arguments, musdb_root_directory, params): audio_filenames=mixtures, audio_codec='wav', output_path=join(audio_output_directory, _SPLIT), - output_naming=_NAMING, max_duration=600., MWF=arguments.MWF, verbose=arguments.verbose), diff --git a/spleeter/commands/separate.py b/spleeter/commands/separate.py index 71eed2d..b1f41ab 100644 --- a/spleeter/commands/separate.py +++ b/spleeter/commands/separate.py @@ -11,168 +11,35 @@ -i /path/to/audio1.wav /path/to/audio2.mp3 """ -from multiprocessing import Pool -from os.path import isabs, join, split, splitext -from tempfile import gettempdir - -# pylint: disable=import-error -import tensorflow as tf -import numpy as np -# pylint: enable=import-error - -from ..utils.audio.adapter import get_audio_adapter -from ..utils.audio.convertor import to_n_channels -from ..utils.estimator import create_estimator -from ..utils.tensor import set_tensor_shape +from ..audio.adapter import get_audio_adapter +from ..separator import Separator __email__ = 'research@deezer.com' __author__ = 'Deezer Research' __license__ = 'MIT License' -def get_dataset(audio_adapter, filenames_and_crops, sample_rate, n_channels): - """" - Build a tensorflow dataset of waveform from a filename list wit crop - information. - - Params: - - audio_adapter: An AudioAdapter instance to load audio from. - - filenames_and_crops: list of (audio_filename, start, duration) - tuples separation is performed on each filaneme - from start (in seconds) to start + duration - (in seconds). - - sample_rate: audio sample_rate of the input and output audio - signals - - n_channels: int, number of channels of the input and output - audio signals - - Returns - A tensorflow dataset of waveform to feed a tensorflow estimator in - predict mode. - """ - filenames, starts, ends = list(zip(*filenames_and_crops)) - dataset = tf.data.Dataset.from_tensor_slices({ - 'audio_id': list(filenames), - 'start': list(starts), - 'end': list(ends) - }) - # Load waveform. - dataset = dataset.map( - lambda sample: dict( - sample, - **audio_adapter.load_tf_waveform( - sample['audio_id'], - sample_rate=sample_rate, - offset=sample['start'], - duration=sample['end'] - sample['start'])), - num_parallel_calls=2) - # Filter out error. - dataset = dataset.filter( - lambda sample: tf.logical_not(sample['waveform_error'])) - # Convert waveform to the right number of channels. - dataset = dataset.map( - lambda sample: dict( - sample, - waveform=to_n_channels(sample['waveform'], n_channels))) - # Set number of channels (required for the model). - dataset = dataset.map( - lambda sample: dict( - sample, - waveform=set_tensor_shape(sample['waveform'], (None, n_channels)))) - return dataset - - -def process_audio( - audio_adapter, - filenames_and_crops, estimator, output_path, - sample_rate, n_channels, codec, output_naming): - """ - Perform separation on a list of audio ids. - - Params: - - audio_adapter: Audio adapter to use for audio I/O. - - filenames_and_crops: list of (audio_filename, start, duration) - tuples separation is performed on each filaneme - from start (in seconds) to start + duration - (in seconds). - - estimator: the tensorflow estimator that performs the - source separation. - - output_path: output_path where to export separated files. - - sample_rate: audio sample_rate of the input and output audio - signals - - n_channels: int, number of channels of the input and output - audio signals - - codec: string codec to be used for export (could be - "wav", "mp3", "ogg", "m4a") could be anything - supported by ffmpeg. - - output_naming: string (= "filename" of "directory") - naming convention for output. - for an input file /path/to/audio/input_file.wav: - * if output_naming is equal to "filename": - output files will be put in the directory /input_file - (/input_file/., - /input_file/....). - * if output_naming is equal to "directory": - output files will be put in the directory /audio/ - (/audio/., - /audio/....) - Use "directory" when separating the MusDB dataset. - - """ - # Get estimator - prediction = estimator.predict( - lambda: get_dataset( - audio_adapter, - filenames_and_crops, - sample_rate, - n_channels), - yield_single_examples=False) - # initialize pool for audio export - pool = Pool(16) - for sample in prediction: - sample_filename = sample.pop('audio_id', 'unknown_filename').decode() - input_directory, input_filename = split(sample_filename) - if output_naming == 'directory': - output_dirname = split(input_directory)[1] - elif output_naming == 'filename': - output_dirname = splitext(input_filename)[0] - else: - raise ValueError(f'Unknown output naming {output_naming}') - for instrument, waveform in sample.items(): - filename = join( - output_path, - output_dirname, - f'{instrument}.{codec}') - pool.apply_async( - audio_adapter.save, - (filename, waveform, sample_rate, codec)) - # Wait for everything to be written - pool.close() - pool.join() - - def entrypoint(arguments, params): """ Command entrypoint. :param arguments: Command line parsed argument as argparse.Namespace. :param params: Deserialized JSON configuration file provided in CLI args. """ + # TODO: check with output naming. audio_adapter = get_audio_adapter(arguments.audio_adapter) - filenames = arguments.audio_filenames - output_path = arguments.output_path - max_duration = arguments.max_duration - audio_codec = arguments.audio_codec - output_naming = arguments.output_naming - estimator = create_estimator(params, arguments.MWF) - filenames_and_crops = [ - (filename, 0., max_duration) - for filename in filenames] - process_audio( - audio_adapter, - filenames_and_crops, - estimator, - output_path, - params['sample_rate'], - params['n_channels'], - codec=audio_codec, - output_naming=output_naming) + separator = Separator( + arguments.configuration, + arguments.MWF) + for filename in arguments.inputs: + separator.separate_to_file( + filename, + arguments.output_path, + audio_adapter=audio_adapter, + offset=arguments.offset, + duration=arguments.duration, + codec=arguments.codec, + bitrate=arguments.bitrate, + filename_format=arguments.filename_format, + synchronous=False + ) + separator.join() diff --git a/spleeter/commands/train.py b/spleeter/commands/train.py index 2814ae6..2a40c84 100644 --- a/spleeter/commands/train.py +++ b/spleeter/commands/train.py @@ -13,9 +13,10 @@ from functools import partial import tensorflow as tf # pylint: enable=import-error +from ..audio.adapter import get_audio_adapter from ..dataset import get_training_dataset, get_validation_dataset from ..model import model_fn -from ..utils.audio.adapter import get_audio_adapter +from ..model.provider import ModelProvider from ..utils.logging import get_logger __email__ = 'research@deezer.com' @@ -95,4 +96,5 @@ def entrypoint(arguments, params): estimator, train_spec, evaluation_spec) + ModelProvider.writeProbe(params['model_dir']) get_logger().info('Model training done') diff --git a/spleeter/dataset.py b/spleeter/dataset.py index 236923a..fe2d349 100644 --- a/spleeter/dataset.py +++ b/spleeter/dataset.py @@ -2,15 +2,16 @@ # coding: utf8 """ - Module for building data preprocessing pipeline using the tensorflow data - API. - Data preprocessing such as audio loading, spectrogram computation, cropping, - feature caching or data augmentation is done using a tensorflow dataset object - that output a tuple (input_, output) where: - - input_ is a dictionary with a single key that contains the (batched) mix - spectrogram of audio samples - - output is a dictionary of spectrogram of the isolated tracks (ground truth) + Module for building data preprocessing pipeline using the tensorflow + data API. Data preprocessing such as audio loading, spectrogram + computation, cropping, feature caching or data augmentation is done + using a tensorflow dataset object that output a tuple (input_, output) + where: + - input is a dictionary with a single key that contains the (batched) + mix spectrogram of audio samples + - output is a dictionary of spectrogram of the isolated tracks + (ground truth) """ import time @@ -23,10 +24,10 @@ import numpy as np import tensorflow as tf # pylint: enable=import-error -from .utils.audio.convertor import ( +from .audio.convertor import ( db_uint_spectrogram_to_gain, spectrogram_to_db_uint) -from .utils.audio.spectrogram import ( +from .audio.spectrogram import ( compute_spectrogram_tf, random_pitch_shift, random_time_stretch) @@ -41,15 +42,6 @@ __email__ = 'research@deezer.com' __author__ = 'Deezer Research' __license__ = 'MIT License' -# Default datasets path parameter to use. -DEFAULT_DATASETS_PATH = join( - 'audio_database', - 'separated_sources', - 'experiments', - 'karaoke_vocal_extraction', - 'tensorflow_experiment' -) - # Default audio parameters to use. DEFAULT_AUDIO_PARAMS = { 'instrument_list': ('vocals', 'accompaniment'), diff --git a/spleeter/model/provider/__init__.py b/spleeter/model/provider/__init__.py index 854b065..3aa3d8d 100644 --- a/spleeter/model/provider/__init__.py +++ b/spleeter/model/provider/__init__.py @@ -38,12 +38,14 @@ class ModelProvider(ABC): """ pass - def writeProbe(self, directory): + @staticmethod + def writeProbe(directory): """ Write a model probe file into the given directory. :param directory: Directory to write probe into. """ - with open(join(directory, self.MODEL_PROBE_PATH), 'w') as stream: + probe = join(directory, ModelProvider.MODEL_PROBE_PATH) + with open(probe, 'w') as stream: stream.write('OK') def get(self, model_directory): diff --git a/spleeter/model/provider/github.py b/spleeter/model/provider/github.py index aad0c44..fdd3d80 100644 --- a/spleeter/model/provider/github.py +++ b/spleeter/model/provider/github.py @@ -14,11 +14,10 @@ >>> provider.download('2stems', '/path/to/local/storage') """ +import hashlib import tarfile -from os import environ -from tempfile import TemporaryFile -from shutil import copyfileobj +from tempfile import NamedTemporaryFile import requests @@ -30,11 +29,25 @@ __author__ = 'Deezer Research' __license__ = 'MIT License' +def compute_file_checksum(path): + """ Computes given path file sha256. + + :param path: Path of the file to compute checksum for. + :returns: File checksum. + """ + sha256 = hashlib.sha256() + with open(path, 'rb') as stream: + for chunk in iter(lambda: stream.read(4096), b''): + sha256.update(chunk) + return sha256.hexdigest() + + class GithubModelProvider(ModelProvider): """ A ModelProvider implementation backed on Github for remote storage. """ LATEST_RELEASE = 'v1.4.0' RELEASE_PATH = 'releases/download' + CHECKSUM_INDEX = 'checksum.json' def __init__(self, host, repository, release): """ Default constructor. @@ -47,6 +60,26 @@ class GithubModelProvider(ModelProvider): self._repository = repository self._release = release + def checksum(self, name): + """ Downloads and returns reference checksum for the given model name. + + :param name: Name of the model to get checksum for. + :returns: Checksum of the required model. + :raise ValueError: If the given model name is not indexed. + """ + url = '{}/{}/{}/{}/{}'.format( + self._host, + self._repository, + self.RELEASE_PATH, + self._release, + self.CHECKSUM_INDEX) + response = requests.get(url) + response.raise_for_status() + index = response.json() + if name not in index: + raise ValueError('No checksum for model {}'.format(name)) + return index[name] + def download(self, name, path): """ Download model denoted by the given name to disk. @@ -60,14 +93,19 @@ class GithubModelProvider(ModelProvider): self._release, name) get_logger().info('Downloading model archive %s', url) - response = requests.get(url, stream=True) - if response.status_code != 200: - raise IOError(f'Resource {url} not found') - with TemporaryFile() as stream: - copyfileobj(response.raw, stream) + with requests.get(url, stream=True) as response: + response.raise_for_status() + archive = NamedTemporaryFile(delete=False) + with archive as stream: + # Note: check for chunk size parameters ? + for chunk in response.iter_content(chunk_size=8192): + if chunk: + stream.write(chunk) + get_logger().info('Validating archive checksum') + if compute_file_checksum(archive.name) != self.checksum(name): + raise IOError('Downloaded file is corrupted, please retry') get_logger().info('Extracting downloaded %s archive', name) - stream.seek(0) - tar = tarfile.open(fileobj=stream) + tar = tarfile.open(name=archive.name) tar.extractall(path=path) tar.close() get_logger().info('%s model file(s) extracted', name) diff --git a/spleeter/separator.py b/spleeter/separator.py index a238037..6c46315 100644 --- a/spleeter/separator.py +++ b/spleeter/separator.py @@ -18,11 +18,12 @@ import json from functools import partial from multiprocessing import Pool from pathlib import Path -from os.path import join +from os.path import basename, join +from . import SpleeterError +from .audio.adapter import get_default_audio_adapter +from .audio.convertor import to_stereo from .model import model_fn -from .utils.audio.adapter import get_default_audio_adapter -from .utils.audio.convertor import to_stereo from .utils.configuration import load_configuration from .utils.estimator import create_estimator, to_predictor @@ -57,7 +58,7 @@ class Separator(object): self._predictor = to_predictor(estimator) return self._predictor - def join(self, timeout=20): + def join(self, timeout=200): """ Wait for all pending tasks to be finished. :param timeout: (Optional) task waiting timeout. @@ -93,10 +94,13 @@ class Separator(object): self, audio_descriptor, destination, audio_adapter=get_default_audio_adapter(), offset=0, duration=600., codec='wav', bitrate='128k', - synchronous=True): + filename_format='{filename}/{instrument}.{codec}', synchronous=True): """ Performs source separation and export result to file using given audio adapter. + Filename format should be a Python formattable string that could use + following parameters : {instrument}, {filename} and {codec}. + :param audio_descriptor: Describe song to separate, used by audio adapter to retrieve and load audio data, in case of file based audio adapter, such @@ -107,6 +111,7 @@ class Separator(object): :param duration: (Optional) Duration of loaded song. :param codec: (Optional) Export codec. :param bitrate: (Optional) Export bitrate. + :param filename_format: (Optional) Filename format. :param synchronous: (Optional) True is should by synchronous. """ waveform, _ = audio_adapter.load( @@ -115,9 +120,20 @@ class Separator(object): duration=duration, sample_rate=self._sample_rate) sources = self.separate(waveform) + filename = basename(audio_descriptor) + generated = [] for instrument, data in sources.items(): + path = join(destination, filename_format.format( + filename=filename, + instrument=instrument, + codec=codec)) + if path in generated: + raise SpleeterError(( + f'Separated source path conflict : {path},' + 'please check your filename format')) + generated.append(path) task = self._pool.apply_async(audio_adapter.save, ( - join(destination, f'{instrument}.{codec}'), + path, data, self._sample_rate, codec, diff --git a/spleeter/utils/configuration.py b/spleeter/utils/configuration.py index 03db200..d1fb167 100644 --- a/spleeter/utils/configuration.py +++ b/spleeter/utils/configuration.py @@ -13,7 +13,7 @@ except ImportError: from os.path import exists -from .. import resources +from .. import resources, SpleeterError __email__ = 'research@deezer.com' @@ -31,17 +31,17 @@ def load_configuration(descriptor): :param descriptor: Configuration descriptor to use for lookup. :returns: Loaded description as dict. :raise ValueError: If required embedded configuration does not exists. - :raise IOError: If required configuration file does not exists. + :raise SpleeterError: If required configuration file does not exists. """ # Embedded configuration reading. if descriptor.startswith(_EMBEDDED_CONFIGURATION_PREFIX): name = descriptor[len(_EMBEDDED_CONFIGURATION_PREFIX):] if not loader.is_resource(resources, f'{name}.json'): - raise ValueError(f'No embedded configuration {name} found') + raise SpleeterError(f'No embedded configuration {name} found') with loader.open_text(resources, f'{name}.json') as stream: return json.load(stream) # Standard file reading. if not exists(descriptor): - raise IOError(f'Configuration file {descriptor} not found') + raise SpleeterError(f'Configuration file {descriptor} not found') with open(descriptor, 'r') as stream: return json.load(stream) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..f584f49 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python +# coding: utf8 + +""" Unit testing package. """ + +__email__ = 'research@deezer.com' +__author__ = 'Deezer Research' +__license__ = 'MIT License' diff --git a/tests/test_ffmpeg_adapter.py b/tests/test_ffmpeg_adapter.py new file mode 100644 index 0000000..195b737 --- /dev/null +++ b/tests/test_ffmpeg_adapter.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python +# coding: utf8 + +""" Unit testing for audio adapter. """ + +__email__ = 'research@deezer.com' +__author__ = 'Deezer Research' +__license__ = 'MIT License' + +from os.path import join +from tempfile import TemporaryDirectory + +# pylint: disable=import-error +from pytest import fixture, raises + +import numpy as np +import ffmpeg +# pylint: enable=import-error + +from spleeter import SpleeterError +from spleeter.audio.adapter import AudioAdapter +from spleeter.audio.adapter import get_default_audio_adapter +from spleeter.audio.adapter import get_audio_adapter +from spleeter.audio.ffmpeg import FFMPEGProcessAudioAdapter + +TEST_AUDIO_DESCRIPTOR = 'audio_example.mp3' +TEST_OFFSET = 0 +TEST_DURATION = 600. +TEST_SAMPLE_RATE = 44100 + + +@fixture(scope='session') +def adapter(): + """ Target test audio adapter fixture. """ + return get_default_audio_adapter() + + +@fixture(scope='session') +def audio_data(adapter): + """ Audio data fixture based on sample loading from adapter. """ + return adapter.load( + TEST_AUDIO_DESCRIPTOR, + TEST_OFFSET, + TEST_DURATION, + TEST_SAMPLE_RATE) + + +def test_default_adapter(adapter): + """ Test adapter as default adapter. """ + assert isinstance(adapter, FFMPEGProcessAudioAdapter) + assert adapter is AudioAdapter.DEFAULT + + +def test_load(audio_data): + """ Test audio loading. """ + waveform, sample_rate = audio_data + assert sample_rate == TEST_SAMPLE_RATE + assert waveform is not None + assert waveform.dtype == np.dtype('float32') + assert len(waveform.shape) == 2 + assert waveform.shape[0] == 479832 + assert waveform.shape[1] == 2 + + +def test_load_error(adapter): + """ Test load ffprobe exception """ + with raises(SpleeterError): + adapter.load( + 'Paris City Jazz', + TEST_OFFSET, + TEST_DURATION, + TEST_SAMPLE_RATE) + + +def test_save(adapter, audio_data): + """ Test audio saving. """ + with TemporaryDirectory() as directory: + path = join(directory, 'ffmpeg-save.mp3') + adapter.save( + path, + audio_data[0], + audio_data[1]) + probe = ffmpeg.probe(TEST_AUDIO_DESCRIPTOR) + assert len(probe['streams']) == 1 + stream = probe['streams'][0] + assert stream['codec_type'] == 'audio' + assert stream['channels'] == 2 + assert stream['duration'] == '10.919184' diff --git a/tests/test_github_model_provider.py b/tests/test_github_model_provider.py new file mode 100644 index 0000000..248b1d5 --- /dev/null +++ b/tests/test_github_model_provider.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python +# coding: utf8 + +""" TO DOCUMENT """ + +from pytest import raises + +from spleeter.model.provider import get_default_model_provider + + +def test_checksum(): + """ Test archive checksum index retrieval. """ + provider = get_default_model_provider() + assert provider.checksum('2stems') == \ + 'f3a90b39dd2874269e8b05a48a86745df897b848c61f3958efc80a39152bd692' + assert provider.checksum('4stems') == \ + '3adb4a50ad4eb18c7c4d65fcf4cf2367a07d48408a5eb7d03cd20067429dfaa8' + assert provider.checksum('5stems') == \ + '25a1e87eb5f75cc72a4d2d5467a0a50ac75f05611f877c278793742513cc7218' + with raises(ValueError): + provider.checksum('laisse moi stems stems stems') diff --git a/tests/test_separator.py b/tests/test_separator.py new file mode 100644 index 0000000..d231987 --- /dev/null +++ b/tests/test_separator.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +# coding: utf8 + +""" Unit testing for Separator class. """ + +__email__ = 'research@deezer.com' +__author__ = 'Deezer Research' +__license__ = 'MIT License' + +import filecmp + +from os.path import basename, exists, join +from tempfile import TemporaryDirectory + +import pytest + +from spleeter import SpleeterError +from spleeter.audio.adapter import get_default_audio_adapter +from spleeter.separator import Separator + +TEST_AUDIO_DESCRIPTOR = 'audio_example.mp3' +TEST_AUDIO_BASENAME = basename(TEST_AUDIO_DESCRIPTOR) +TEST_CONFIGURATIONS = [ + ('spleeter:2stems', ('vocals', 'accompaniment')), + ('spleeter:4stems', ('vocals', 'drums', 'bass', 'other')), + ('spleeter:5stems', ('vocals', 'drums', 'bass', 'piano', 'other')) +] + + +@pytest.mark.parametrize('configuration, instruments', TEST_CONFIGURATIONS) +def test_separate(configuration, instruments): + """ Test separation from raw data. """ + adapter = get_default_audio_adapter() + waveform, _ = adapter.load(TEST_AUDIO_DESCRIPTOR) + separator = Separator(configuration) + prediction = separator.separate(waveform) + assert len(prediction) == len(instruments) + for instrument in instruments: + assert instrument in prediction + for instrument in instruments: + track = prediction[instrument] + assert not (waveform == track).all() + for compared in instruments: + if instrument != compared: + assert not (track == prediction[compared]).all() + + +@pytest.mark.parametrize('configuration, instruments', TEST_CONFIGURATIONS) +def test_separate_to_file(configuration, instruments): + """ Test file based separation. """ + separator = Separator(configuration) + with TemporaryDirectory() as directory: + separator.separate_to_file( + TEST_AUDIO_DESCRIPTOR, + directory) + for instrument in instruments: + assert exists(join( + directory, + '{}/{}.wav'.format(TEST_AUDIO_BASENAME, instrument))) + + +@pytest.mark.parametrize('configuration, instruments', TEST_CONFIGURATIONS) +def test_filename_format(configuration, instruments): + """ Test custom filename format. """ + separator = Separator(configuration) + with TemporaryDirectory() as directory: + separator.separate_to_file( + TEST_AUDIO_DESCRIPTOR, + directory, + filename_format='export/{filename}/{instrument}.{codec}') + for instrument in instruments: + assert exists(join( + directory, + 'export/{}/{}.wav'.format(TEST_AUDIO_BASENAME, instrument))) + + +def test_filename_confilct(): + """ Test error handling with static pattern. """ + separator = Separator(TEST_CONFIGURATIONS[0][0]) + with TemporaryDirectory() as directory: + with pytest.raises(SpleeterError): + separator.separate_to_file( + TEST_AUDIO_DESCRIPTOR, + directory, + filename_format='I wanna be your lover')