Nikolay-Lysenko/sinethesizer

View on GitHub
sinethesizer/effects/filter_sweep.py

Summary

Maintainability
A
1 hr
Test Coverage
"""
Apply sound effects based on filters with oscillating cutoffs.

This type of effects is a partial case of equalizer automation. If automation
based on a free-form envelope and not on an oscillator is needed, look at
`automation` module.

Author: Nikolay Lysenko
"""


from typing import Optional

import numpy as np

from sinethesizer.effects.filter import apply_frequency_filter
from sinethesizer.oscillators import generate_mono_wave
from sinethesizer.utils.misc import mix_with_original_sound


def oscillate_between_sounds(
        sounds: np.ndarray, frame_rate: int, frequency: float,
        phase: float = 0.0, waveform: str = 'sine'
) -> np.ndarray:
    """
    Combine multiple sounds into one sound by oscillating between them.

    :param sounds:
        array of shape (n_sounds, n_channels, n_frames)
    :param frame_rate:
        number of frames per second
    :param frequency:
        frequency of oscillations between sound sources
    :param phase:
        phase shift of oscillations (in radians)
    :param waveform:
        form of oscillations wave
    :return:
        sound composed of input sounds
    """
    thresholds = np.linspace(-1, 1, sounds.shape[0])
    weights = np.tile(thresholds.reshape((-1, 1)), (1, sounds.shape[2]))
    wave = generate_mono_wave(waveform, frequency, np.ones(sounds.shape[2]), frame_rate, phase)
    step = 2 / (sounds.shape[0] - 1)
    weights = (1 - np.abs(weights - wave) / step) * (np.abs(weights - wave) < step)
    weights = weights.reshape((weights.shape[0], 1, weights.shape[1]))
    sound = np.sum(sounds * weights, axis=0)
    return sound


@mix_with_original_sound
def apply_filter_sweep(
        sound: np.ndarray, event: 'sinethesizer.synth.core.Event',
        kind: str = 'absolute',
        bands: list[tuple[Optional[float], Optional[float]]] = None,
        invert: bool = False, order: int = 25,
        frequency: float = 6, phase: float = 0.0, waveform: str = 'sine'
) -> np.ndarray:
    """
    Filter some frequencies with oscillating cutoffs.

    :param sound:
        sound to be modified
    :param event:
        parameters of sound event for which this function is called
    :param kind:
        if it is set to 'absolute', `bands` must be defined in Hz;
        if it is set to 'relative', `bands` must be defined as ratios to
        fundamental frequency
    :param bands:
        list of pairs of minimum and maximum cutoff frequencies;
        oscillations are between sounds obtained from input sound after
        applying filters with these cutoff frequencies
    :param invert:
        if it is `True`, for bands with both cutoff frequencies set not to
        `None`, band-stop filters are applied instead of band-pass filters
    :param order:
        order of filters; the higher it is, the steeper cutoffs are
    :param frequency:
        frequency of oscillations between filtered sounds (in Hz)
    :param phase:
        phase shift of sweeping band oscillations (in radians)
    :param waveform:
        form of wave that specifies oscillations between filtered sounds
    :return:
        sound filtered with varying cutoff frequencies
    """
    bands = bands or [(None, None)]
    if len(bands) == 1:
        sound = apply_frequency_filter(sound, event, kind, bands[0][0], bands[0][1], invert, order)
        return sound
    filtered_sounds = [
        apply_frequency_filter(
            sound, event, kind, min_cutoff_frequency, max_cutoff_frequency, invert, order
        )
        for min_cutoff_frequency, max_cutoff_frequency in bands
    ]
    filtered_sounds = [x.reshape((1, x.shape[0], x.shape[1])) for x in filtered_sounds]
    filtered_sounds = np.concatenate(filtered_sounds)
    sound = oscillate_between_sounds(filtered_sounds, event.frame_rate, frequency, phase, waveform)
    return sound


def apply_absolute_phaser(
        sound: np.ndarray, event: 'sinethesizer.synth.core.Event',
        min_center: float = 220, max_center: float = 880,
        band_width: float = 20, n_bands: int = 10, order: int = 25,
        frequency: float = 5, phase: float = 0.0, waveform: str = 'sine',
        original_share: float = 0.75, wahwah: bool = False
) -> np.ndarray:
    """
    Apply phaser effect with border parameters defined in Hz.

    :param sound:
        sound to be modified
    :param event:
        parameters of sound event for which this function is called
    :param min_center:
        central frequency of the lowest band (in Hz)
    :param max_center:
        central frequency of the highest band (in Hz)
    :param band_width:
        width of sweeping band (in Hz)
    :param n_bands:
        number of band positions to consider; the higher it is, the more close
        to classical phaser result is, but also the longer computations are
        and the higher RAM consumption is during track creation
    :param order:
        order of filters; the higher it is, the steeper cutoffs are
    :param frequency:
        frequency of sweeping band oscillations;
        the higher it is, the more input sound is distorted
    :param phase:
        phase shift of sweeping band oscillations (in radians)
    :param waveform:
        form of wave of sweeping band oscillations
    :param original_share:
        share of original sound in resulting sound
    :param wahwah:
        if it is `True`, band-pass filters are used instead of band-stop
        filters and so the effect to be applied is called wah-wah, not phaser
    :return:
        phased sound
    """
    bands = [
        (center - band_width / 2, center + band_width / 2)
        for center in np.linspace(min_center, max_center, n_bands)
    ]
    invert = not wahwah
    filtered_sound = apply_filter_sweep(
        sound, event, 'absolute', bands, invert, order, frequency, phase, waveform
    )
    sound = original_share * sound + (1 - original_share) * filtered_sound
    return sound


def apply_relative_phaser(
        sound: np.ndarray, event: 'sinethesizer.synth.core.Event',
        min_center_ratio: float = 1.0, max_center_ratio: float = 4.0,
        relative_band_width: float = 0.1, n_bands: int = 10, order: int = 25,
        frequency: float = 5, phase: float = 0.0, waveform: str = 'sine',
        original_share: float = 0.75, wahwah: bool = False
) -> np.ndarray:
    """
    Apply phaser effect with border parameters defined as ratios.

    :param sound:
        sound to be modified
    :param event:
        parameters of sound event for which this function is called
    :param min_center_ratio:
        central frequency of the lowest band as ratio to fundamental frequency
    :param max_center_ratio:
        central frequency of the highest band as ratio to fundamental frequency
    :param relative_band_width:
        width of sweeping band as ratio to fundamental frequency
    :param n_bands:
        number of band positions to consider; the higher it is, the more close
        to classical phaser result is, but also the longer computations are
        and the higher RAM consumption is during track creation
    :param order:
        order of filters; the higher it is, the steeper cutoffs are
    :param frequency:
        frequency of sweeping band oscillations;
        the higher it is, the more input sound is distorted
    :param phase:
        phase shift of sweeping band oscillations (in radians)
    :param waveform:
        form of wave of sweeping band oscillations
    :param original_share:
        share of original sound in resulting sound
    :param wahwah:
        if it is `True`, band-pass filters are used instead of band-stop
        filters and so the effect to be applied is called wah-wah, not phaser
    :return:
        phased sound
    """
    fundamental_frequency = event.frequency
    min_center = min_center_ratio * fundamental_frequency
    max_center = max_center_ratio * fundamental_frequency
    band_width = relative_band_width * fundamental_frequency
    sound = apply_absolute_phaser(
        sound, event, min_center, max_center, band_width,
        n_bands, order, frequency, phase, waveform, original_share, wahwah
    )
    return sound


@mix_with_original_sound
def apply_phaser(
        sound: np.ndarray, event: 'sinethesizer.synth.core.Event',
        kind: str = 'absolute', *args, **kwargs
) -> np.ndarray:
    """
    Apply phaser effect.

    Here, phaser is defined as weighted sum of:
    1) original sound;
    2) original sound modified by sweeping band-stop filter of narrow band.

    Note that playing with arguments can significantly change resulting sound
    and some settings produce awkward non-musical sounds. Also note that this
    effect should be applied only to sounds with rich spectrum.

    :param sound:
        sound to be modified
    :param event:
        parameters of sound event for which this function is called
    :param kind:
        kind of phaser; supported values are 'absolute' and 'relative'
    :return:
        phased sound
    """
    if kind == 'absolute':
        sound = apply_absolute_phaser(sound, event, *args, **kwargs)
    elif kind == 'relative':
        sound = apply_relative_phaser(sound, event, *args, **kwargs)
    else:
        raise ValueError(f"Kind must be either 'absolute' or 'relative', but found: {kind}")
    return sound