From bee30eac5e625f99a0f1784ef3e248f9895641ef Mon Sep 17 00:00:00 2001 From: Jael Gu Date: Wed, 27 Jul 2022 17:12:17 +0800 Subject: [PATCH] Add files Signed-off-by: Jael Gu --- README.md | 95 +++++++++++++++++++++++++- __init__.py | 19 ++++++ configs.py | 36 ++++++++++ nn_fingerprint.py | 135 +++++++++++++++++++++++++++++++++++++ result1.png | Bin 0 -> 3863 bytes result2.png | Bin 0 -> 6026 bytes saved_model/pfann_fma_m.pt | 3 + saved_model/pfann_fma_s.pt | 3 + 8 files changed, 290 insertions(+), 1 deletion(-) create mode 100644 __init__.py create mode 100644 configs.py create mode 100644 nn_fingerprint.py create mode 100644 result1.png create mode 100644 result2.png create mode 100644 saved_model/pfann_fma_m.pt create mode 100644 saved_model/pfann_fma_s.pt diff --git a/README.md b/README.md index aa69c20..455c064 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,95 @@ -# nnfp +# Audio Embedding with Neural Network Fingerprint +*Author: [Jael Gu](https://github.com/jaelgu)* + +
+ +## Description + +The audio embedding operator converts an input audio into a dense vector which can be used to represent the audio clip's semantics. +Each vector represents for an audio clip with a fixed length of around 1s. +This operator generates audio embeddings with fingerprinting method introduced by [Neural Audio Fingerprint](https://arxiv.org/abs/2010.11910). +The model is implemented in Pytorch. +We've also trained the nnfp model with [FMA dataset](https://github.com/mdeff/fma) (& some noise audio) and shared weights in this operator. +The nnfp operator is suitable to generate audio fingerprints. + +
+ +## Code Example + +Generate embeddings for the audio "test.wav". + +*Write the pipeline in simplified style*: + +```python +import towhee + +( + towhee.glob('test.wav') + .audio_decode.ffmpeg() + .runas_op(func=lambda x:[y[0] for y in x]) + .audio_embedding.nnfp() # use default model + .show() +) +``` + + +*Write a same pipeline with explicit inputs/outputs name specifications:* + +```python +import towhee + +( + towhee.glob['path']('test.wav') + .audio_decode.ffmpeg['path', 'frames']() + .runas_op['frames', 'frames'](func=lambda x:[y[0] for y in x]) + .audio_embedding.nnfp['frames', 'vecs']() + .select['path', 'vecs']() + .show() +) +``` + + +
+ +## Factory Constructor + +Create the operator via the following factory method + +***audio_embedding.nnfp(params=None, checkpoint_path=None, framework='pytorch')*** + +**Parameters:** + +*params: dict* + +A dictionary of model parameters. If None, it will use default parameters to create model. + +*checkpoint_path: str* + +The path to model weights. If None, it will load default model weights. + +*framework: str* + +The framework of model implementation. +Default value is "pytorch" since the model is implemented in Pytorch. + +
+ +## Interface + +An audio embedding operator generates vectors in numpy.ndarray given towhee audio frames. + +**Parameters:** + +*data: List[towhee.types.audio_frame.AudioFrame]* + +Input audio data is a list of towhee audio frames. +The input data should represent for an audio longer than 1s. + + +**Returns**: + +*numpy.ndarray* + +Audio embeddings in shape (num_clips, 128). +Each embedding stands for features of an audio clip with length of 1s. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..e7dc4ec --- /dev/null +++ b/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2021 Zilliz. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from .nn_fingerprint import NNFingerprint + + +def nnfp(): + return NNFingerprint() diff --git a/configs.py b/configs.py new file mode 100644 index 0000000..2388120 --- /dev/null +++ b/configs.py @@ -0,0 +1,36 @@ +# Parameter configs for nnfp, inspired by https://github.com/stdio2016/pfann +# +# Copyright 2021 Zilliz. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +default_params = { + "dim": 128, + "h": 1024, + "u": 32, + "fuller": True, + "activation": "relu", + "sample_rate": 8000, + "window_length": 1024, + "hop_length": 256, + "n_mels": 256, + "f_min": 300, + "f_max": 4000, + "segment_size": 1, + "hop_size": 1, + "frame_shift_mul": 1, + "naf_mode": False, + "mel_log": "log", + "spec_norm": "l2" +} diff --git a/nn_fingerprint.py b/nn_fingerprint.py new file mode 100644 index 0000000..fd6e651 --- /dev/null +++ b/nn_fingerprint.py @@ -0,0 +1,135 @@ +# Copyright 2021 Zilliz. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import warnings + +import os +from pathlib import Path +from typing import List + +import torch +import numpy +import resampy + +from towhee.operator.base import NNOperator +from towhee import register +from towhee.types.audio_frame import AudioFrame +from towhee.models.nnfp import NNFp +from towhee.models.utils.audio_preprocess import preprocess_wav, MelSpec + +from .configs import default_params + +warnings.filterwarnings('ignore') +log = logging.getLogger() + + +@register(output_schema=['vecs']) +class NNFingerprint(NNOperator): + """ + Audio embedding operator using Neural Network Fingerprint + """ + + def __init__(self, + params: dict = None, + checkpoint_path: str = None, + framework: str = 'pytorch'): + super().__init__(framework=framework) + self.device = "cuda" if torch.cuda.is_available() else "cpu" + if params is None: + self.params = default_params + else: + self.params = params + + dim = self.params['dim'] + h = self.params['h'] + u = self.params['u'] + f_bin = self.params['n_mels'] + n_seg = int(self.params['segment_size'] * self.params['sample_rate']) + t = (n_seg + self.params['hop_length'] - 1) // self.params['hop_length'] + + log.info('Creating model...') + self.model = NNFp( + dim=dim, h=h, u=u, + in_f=f_bin, in_t=t, + fuller=self.params['fuller'], + activation=self.params['activation'] + ).to(self.device) + + log.info('Loading weights...') + if checkpoint_path is None: + path = str(Path(__file__).parent) + checkpoint_path = os.path.join(path, './checkpoints/pfann_fma_m.pt') + state_dict = torch.load(checkpoint_path, map_location=self.device) + self.model.load_state_dict(state_dict) + self.model.eval() + log.info('Model is loaded.') + + def __call__(self, data: List[AudioFrame]) -> numpy.ndarray: + audio_tensors = self.preprocess(data).to(self.device) + features = self.model(audio_tensors) + return features.detach().cpu().numpy() + + def preprocess(self, frames: List[AudioFrame]): + sr = frames[0].sample_rate + layout = frames[0].layout + if layout == 'stereo': + frames = [frame.reshape(-1, 2) for frame in frames] + audio = numpy.vstack(frames).transpose() + else: + audio = numpy.hstack(frames) + audio = audio[None, :] + + audio = self.int2float(audio) + + if sr != self.params['sample_rate']: + audio = resampy.resample(audio, sr, self.params['sample_rate']) + + wav = preprocess_wav(audio, + segment_size=int(self.params['sample_rate'] * self.params['segment_size']), + hop_size=int(self.params['sample_rate'] * self.params['hop_size']), + frame_shift_mul=self.params['frame_shift_mul']).to(self.device) + wav = wav.to(torch.float32) + mel = MelSpec(sample_rate=self.params['sample_rate'], + window_length=self.params['window_length'], + hop_length=self.params['hop_length'], + f_min=self.params['f_min'], + f_max=self.params['f_max'], + n_mels=self.params['n_mels'], + naf_mode=self.params['naf_mode'], + mel_log=self.params['mel_log'], + spec_norm=self.params['spec_norm']).to(self.device) + wav = mel(wav) + return wav + + @staticmethod + def int2float(wav: numpy.ndarray, dtype: str = 'float64'): + """ + Convert audio imgs from int to float. + The input dtype must be integers. + The output dtype is controlled by the parameter `dtype`, defaults to 'float64'. + + The code is inspired by https://github.com/mgeier/python-audio/blob/master/audio-files/utility.py + """ + dtype = numpy.dtype(dtype) + assert dtype.kind == 'f' + + if wav.dtype.kind in 'iu': + ii = numpy.iinfo(wav.dtype) + abs_max = 2 ** (ii.bits - 1) + offset = ii.min + abs_max + return (wav.astype(dtype) - offset) / abs_max + else: + log.warning('Converting float dtype from %s to %s.', wav.dtype, dtype) + return wav.astype(dtype) diff --git a/result1.png b/result1.png new file mode 100644 index 0000000000000000000000000000000000000000..5bbbf1a245edc9812e413578f7f0389dee7f9164 GIT binary patch literal 3863 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