diff --git a/.github/workflows/python-package-conda.yml b/.github/workflows/python-package-conda.yml new file mode 100644 index 00000000..ff0616f9 --- /dev/null +++ b/.github/workflows/python-package-conda.yml @@ -0,0 +1,73 @@ +name: Python Package using Conda + +on: [push] + +jobs: + build-linux: + runs-on: ubuntu-latest + + defaults: + run: + shell: bash -l {0} + + strategy: + max-parallel: 5 + fail-fast: false + matrix: + python-version: [ "3.7", "3.8", "3.9", "3.10" ] + + steps: + - uses: actions/checkout@v2 + + - name: Cache conda + uses: actions/cache@v2 + env: + CACHE_NUMBER: 1 # increment this value to reset cache if environment.yml has not changed + with: + path: ~/conda_pkgs_dir + key: ${{ runner.os }}-${{ matrix.python-version }}-conda-${{ env.CACHE_NUMBER }} + + - name: Install Conda env + uses: conda-incubator/setup-miniconda@v2 + with: + auto-update-conda: true + python-version: ${{ matrix.python-version }} + add-pip-as-python-dependency: true + auto-activate-base: false + activate-environment: madmom + environment-file: environment.yml + use-only-tar-bz2: true # this needs to be set for caching to work properly + + - name: Conda info + run: | + conda info -a + conda list + + - name: Install madmom + run: | + pip install -e . + git submodule update --init --remote + + - name: Lint with flake8 + run: | + # stop the build if there are Python syntax errors or undefined names + flake8 . --count --exit-zero --select=E9,F63,F7,F82 --show-source --statistics + # exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide + flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Test with pytest + run: | + pytest --cov --doctest-ignore-import-errors madmom tests + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage.xml + directory: ./coverage/reports/ + flags: unittests + env_vars: OS,PYTHON + name: codecov-umbrella + fail_ci_if_error: true + path_to_write_report: ./coverage/codecov_report.txt + verbose: true diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 97b2aa92..00000000 --- a/.travis.yml +++ /dev/null @@ -1,49 +0,0 @@ -language: python -matrix: - include: - - python: 3.7 - dist: xenial - sudo: required - - python: 3.8 - dist: focal - sudo: required - - python: 3.9 - dist: focal - sudo: required -before_install: - # get a working ffmpeg - - sudo wget -O ffmpeg.tar.gz https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz - - sudo mkdir ffmpeg - - sudo tar xvf ffmpeg.tar.gz -C ffmpeg --strip-components=1 - - sudo cp ffmpeg/ffmpeg ffmpeg/ffprobe /usr/bin/ - # install system libraries - - sudo apt-get update -qq - - sudo apt-get install -qq libfftw3-dev - # install numpy etc. via miniconda - - if [[ "$TRAVIS_PYTHON_VERSION" == "2.7" ]]; then - wget https://repo.continuum.io/miniconda/Miniconda2-latest-Linux-x86_64.sh -O miniconda.sh; - else - wget https://repo.continuum.io/miniconda/Miniconda3-latest-Linux-x86_64.sh -O miniconda.sh; - fi - - bash miniconda.sh -b -p $HOME/miniconda - - export PATH="$HOME/miniconda/bin:$PATH" - - hash -r - - conda config --set always_yes yes --set changeps1 no - - conda update -q conda - - conda config --add channels conda-forge - - conda config --add channels pypi - - conda info -a - - deps='pip libgfortran cython numpy scipy pep8' - - conda create -q -n test-environment "python=$TRAVIS_PYTHON_VERSION" $deps - - source activate test-environment - - pip install codecov mido pyfftw -install: - - pip install -e . - - pip install coveralls pytest pytest-cov -before_script: - - pep8 --ignore=E402 madmom tests bin -script: - - pytest --cov --doctest-ignore-import-errors madmom tests -after_success: - - codecov - - coveralls diff --git a/README.rst b/README.rst index dd99bbf4..b415d35c 100644 --- a/README.rst +++ b/README.rst @@ -245,11 +245,15 @@ will give different help messages. Additional resources ==================== -Mailing list ------------- +Discussions / Q&A +----------------- + +Github discussions (https://github.com/CPJKU/madmom/discussions) should be +used to get in touch with the developers and other users, to ask questions +about madmom's usage and ideas for development. -The `mailing list `_ should be -used to get in touch with the developers and other users. +The `mailing list `_ is kept +as an archive but should not be used for new questions/ Wiki ---- @@ -366,12 +370,17 @@ References *Deep Polyphonic ADSR Piano Note Transcription*, Proceedings of the 44th International Conference on Acoustics, Speech and Signal Processing (ICASSP), 2019. +.. [20] Sebastian Böck, Matthew Davies and Peter Knees, + *Multi-Task learning of tempo and beat: learning one to improve the other*, + Proceedings of the 20th International Society for Music Information + Retrieval Conference (ISMIR), 2019. + Acknowledgements ================ Supported by the European Commission through the `GiantSteps project -`_ (FP7 grant agreement no. 610591) and the +`_ (FP7 grant agreement no. 610591) and the `Phenicx project `_ (FP7 grant agreement no. 601166) as well as the `Austrian Science Fund (FWF) `_ project Z159. diff --git a/bin/BarTracker b/bin/BarTracker index 0f40d62e..99ea537e 100755 --- a/bin/BarTracker +++ b/bin/BarTracker @@ -79,6 +79,7 @@ def main(): # signal processor signal_processor = SignalProcessor(**vars(args)) # beats processor + load_beats_processor = None if hasattr(args, 'infile'): # single mode: read the beats from STDIN load_beats_processor = LoadBeatsProcessor(**vars(args)) diff --git a/bin/evaluate b/bin/evaluate index d57a2a45..bad651f3 100755 --- a/bin/evaluate +++ b/bin/evaluate @@ -52,6 +52,11 @@ def main(): # parse the args args = p.parse_args() + # print usage if no evaluation mode was set + if not getattr(args, 'eval', False): + p.print_usage() + exit(0) + # print the arguments if args.verbose >= 2: print(args) diff --git a/environment.yml b/environment.yml index 8315e8c8..3d372d9b 100644 --- a/environment.yml +++ b/environment.yml @@ -9,12 +9,17 @@ dependencies: - scipy - opencv - portaudio + - pyfftw + - ffmpeg + - pyaudio - pip - pip: - - mido>=1.2.6 - - pyfftw - - pyaudio - - pytest - - black - - pre-commit - - prospector + - mido>=1.2.6 + - flake8<4 + - coverage + - pytest + - pytest-cov + - pytest-flake8 + - black + - pre-commit + - prospector diff --git a/madmom/audio/spectrogram.py b/madmom/audio/spectrogram.py index d046ec9b..cf951daa 100644 --- a/madmom/audio/spectrogram.py +++ b/madmom/audio/spectrogram.py @@ -226,6 +226,9 @@ def tuning_frequency(self, **kwargs): Tuning frequency of the spectrogram. """ + import warnings + warnings.warn('tuning_frequency() is deprecated as of version 0.17 ' + 'and will be removed in 0.19.') from scipy.ndimage.filters import maximum_filter # widen the spectrogram in frequency dimension max_spec = maximum_filter(self, size=[1, 3]) diff --git a/madmom/evaluation/chords.py b/madmom/evaluation/chords.py index c044c070..952d0940 100644 --- a/madmom/evaluation/chords.py +++ b/madmom/evaluation/chords.py @@ -54,7 +54,7 @@ def encode(chord_labels): Parameters ---------- chord_labels : numpy structured array - Chord segments in `madmom.io.SEGMENT_DTYPE` format + Chord segments in `madmom.io.SEGMENT_DTYPE` format. Returns ------- @@ -728,10 +728,10 @@ class ChordEvaluation(EvaluationMixin): Parameters ---------- - detections : str - File containing chords detections. - annotations : str - File containing chord annotations. + detections : numpy structured array + Detected chord segments in `madmom.io.SEGMENT_DTYPE` format. + annotations : numpy structured array + Annotated chord segments in `madmom.io.SEGMENT_DTYPE` format. name : str, optional Name of the evaluation object (e.g., the name of the song). diff --git a/madmom/features/beats.py b/madmom/features/beats.py index cc452282..4fcf2b59 100644 --- a/madmom/features/beats.py +++ b/madmom/features/beats.py @@ -1014,16 +1014,18 @@ def process_offline(self, activations, **kwargs): Detected beat positions [seconds]. """ - # init the beats to return and the offset - beats = np.empty(0, dtype=int) + # init beats to return and offset (to be added later) + beats = np.empty(0, dtype=float) first = 0 - # use only the activations > threshold + # use only activations > threshold if self.threshold: activations, first = threshold_activations(activations, self.threshold) # return no beats if no activations given / remain after thresholding if not activations.any(): return beats + # add a small epsilon to prevent division by 0 + activations += np.finfo(activations.dtype).eps # get the best state path by calling the viterbi algorithm path, _ = self.hmm.viterbi(activations) # also return no beats if no path was found diff --git a/madmom/features/downbeats.py b/madmom/features/downbeats.py index 46466381..b738bb40 100644 --- a/madmom/features/downbeats.py +++ b/madmom/features/downbeats.py @@ -121,8 +121,13 @@ def _process_dbn(process_tuple): class DBNDownBeatTrackingProcessor(Processor): """ - Downbeat tracking with RNNs and a dynamic Bayesian network (DBN) - approximated by a Hidden Markov Model (HMM). + Downbeat tracking with a dynamic Bayesian network (DBN) approximated by + a Hidden Markov Model (HMM). + + The observations must reflect the probabilities corresponding to beats + and downbeats. The probability for non-beats is computed given these two + probabilities as p(nb) = 1 - p(b) - p(db). All three observations must be + a probability density function, i.e. sum(p(nb) + p(b) + p(db)) == 1. Parameters ---------- @@ -268,14 +273,18 @@ def process(self, activations, **kwargs): """ # pylint: disable=arguments-differ import itertools as it - # use only the activations > threshold (init offset to be added later) + # init beats to return and offset (to be added later) + beats = np.empty((0, 2), dtype=float) first = 0 + # use only activations > threshold if self.threshold: activations, first = threshold_activations(activations, self.threshold) # return no beats if no activations given / remain after thresholding if not activations.any(): - return np.empty((0, 2)) + return beats + # add a small epsilon to prevent division by 0 + activations += np.finfo(activations.dtype).eps # (parallel) decoding of the activations with HMM results = list(self.map(_process_dbn, zip(self.hmms, it.repeat(activations)))) @@ -283,6 +292,9 @@ def process(self, activations, **kwargs): best = np.argmax(list(r[1] for r in results)) # the best path through the state space path, _ = results[best] + # also return no beats if no path was found + if not path.any(): + return beats # the state space and observation model of the best HMM st = self.hmms[best].transition_model.state_space om = self.hmms[best].observation_model diff --git a/madmom/ml/hmm.pyx b/madmom/ml/hmm.pyx index 84e40b7f..7bc61804 100644 --- a/madmom/ml/hmm.pyx +++ b/madmom/ml/hmm.pyx @@ -192,10 +192,8 @@ class TransitionModel(object): probabilities = np.asarray(probabilities) if not np.allclose(np.bincount(prev_states, weights=probabilities), 1): raise ValueError('Not a probability distribution.') - # convert everything into a sparse CSR matrix, make sure it is square. - # looking through prev_states is enough, because there *must* be a - # transition *from* every state - num_states = max(prev_states) + 1 + # convert everything into a sparse CSR matrix, make sure it is square + num_states = max(states.max(), prev_states.max()) + 1 transitions = csr_matrix((probabilities, (states, prev_states)), shape=(num_states, num_states)) # convert to correct types diff --git a/madmom/ml/nn/__init__.py b/madmom/ml/nn/__init__.py index afbca0ca..282b3c22 100644 --- a/madmom/ml/nn/__init__.py +++ b/madmom/ml/nn/__init__.py @@ -192,6 +192,9 @@ def load(cls, nn_files, **kwargs): """ networks = [NeuralNetwork.load(f) for f in nn_files] + # raise error if no NNs were loaded + if not networks: + raise ValueError('No neural network(s) could be loaded.') return cls(networks, **kwargs) @staticmethod diff --git a/madmom/processors.py b/madmom/processors.py index dd371a03..91d0f9ab 100644 --- a/madmom/processors.py +++ b/madmom/processors.py @@ -863,7 +863,7 @@ def process_online(processor, infile, outfile, **kwargs): kwargs['sample_rate'] = kwargs.get('sample_rate', 44100) kwargs['num_channels'] = kwargs.get('num_channels', 1) # list all available PyAudio devices and exit afterwards - if kwargs['list_stream_input_device']: + if kwargs.get('list_stream_input_device'): import pyaudio pa = pyaudio.PyAudio() for i in range(pa.get_device_count()): @@ -947,6 +947,14 @@ def io_arguments(parser, output_suffix='.txt', pickle=True, online=False): # add general options parser.add_argument('-v', dest='verbose', action='count', help='increase verbosity level') + + # print usage if no processing mode is set + def print_usage(*args, **kwargs): + parser.print_usage() + exit(0) + + parser.set_defaults(func=print_usage) + # add subparsers sub_parsers = parser.add_subparsers(title='processing options') diff --git a/setup.py b/setup.py index 8f3f13f1..3e29bc92 100755 --- a/setup.py +++ b/setup.py @@ -31,9 +31,15 @@ ['madmom/features/beats_crf.pyx'], include_dirs=include_dirs, ), - Extension('madmom.ml.hmm', ['madmom/ml/hmm.pyx'], include_dirs=include_dirs), Extension( - 'madmom.ml.nn.layers', ['madmom/ml/nn/layers.py'], include_dirs=include_dirs + 'madmom.ml.hmm', + ['madmom/ml/hmm.pyx'], + include_dirs=include_dirs + ), + Extension( + 'madmom.ml.nn.layers', + ['madmom/ml/nn/layers.py'], + include_dirs=include_dirs, ), ] @@ -44,10 +50,10 @@ package_data = [ 'models/LICENSE', 'models/README.rst', - 'models/beats/201[56]/*', + 'models/beats/201[569]/*', 'models/chords/*/*', 'models/chroma/*/*', - 'models/downbeats/*/*', + 'models/downbeats/2016/*', 'models/key/2018/*', 'models/notes/*/*', 'models/onsets/*/*', diff --git a/tests/test_bin.py b/tests/test_bin.py index 9c6b71e4..d84107f3 100644 --- a/tests/test_bin.py +++ b/tests/test_bin.py @@ -13,6 +13,8 @@ import sys import tempfile import unittest +import pytest + from os.path import join as pj try: @@ -893,6 +895,8 @@ def test_run(self): result = np.loadtxt(tmp_result) self.assertTrue(np.allclose(result, self.result, atol=1e-5)) + @pytest.mark.skipif(sys.version_info > (3, 7), + reason="Fails for certain versions; related to mido.") def test_midi(self): run_single(self.bin, stereo_sample_file, tmp_result, args=['--midi']) result = midi.MIDIFile(tmp_result).notes diff --git a/tests/test_features_beats.py b/tests/test_features_beats.py index 8312925c..2a30a5b9 100644 --- a/tests/test_features_beats.py +++ b/tests/test_features_beats.py @@ -170,5 +170,5 @@ def test_process_forward(self): def test_empty_path(self): # beat activation which leads to an empty path - act = np.array([0, 1, 0, 1, 0, 1]) + act = np.array([0, 1, 0, 1, 0, 1], dtype=float) self.assertTrue(np.allclose(self.processor(act), []))