Source code for partitura.utils.synth

#!/usr/bin/python
# -*- coding: utf-8 -*-
"""
This module contains methods for synthesizing score- or performance-like
objects using additive synthesis

TODO
----
* Add other tuning systems?
"""
from typing import Union, Tuple, Dict, Optional, Any, Callable

import numpy as np

from scipy.interpolate import interp1d


from partitura.utils.music import (
    A4,
    ensure_notearray,
    get_time_units_from_note_array,
    midi_pitch_to_frequency,
    performance_notearray_from_score_notearray,
)

TWO_PI = 2 * np.pi
SAMPLE_RATE = 44100
DTYPE = float

NATURAL_INTERVAL_RATIOS = {
    0: 1,
    1: 16 / 15,  # 15/14, 11/10
    2: 8 / 7,  # 9/8, 10/9, 12/11, 13/14
    3: 6 / 5,  # 7/6,
    4: 5 / 4,
    5: 4 / 3,
    6: 7 / 5,  # 13/9,
    7: 3 / 2,
    8: 8 / 5,
    9: 5 / 3,
    10: 7 / 4,  # 13/7
    11: 15 / 8,
    12: 2,
}

# symmetric five limit temperament with supertonic = 10:9
FIVE_LIMIT_INTERVAL_RATIOS = {
    0: 1,
    1: 16 / 15,
    2: 10 / 9,
    3: 6 / 5,
    4: 5 / 4,
    5: 4 / 3,
    6: 7 / 5,
    7: 3 / 2,
    8: 8 / 5,
    9: 5 / 3,
    10: 9 / 5,
    11: 15 / 8,
    12: 2,
}


def midi_pitch_to_natural_frequency(
    midi_pitch: Union[int, float, np.ndarray],
    a4: Union[int, float] = A4,
    natural_interval_ratios: Dict[int, float] = NATURAL_INTERVAL_RATIOS,
) -> Union[float, np.ndarray]:
    """
    Convert MIDI pitch to frequency in Hz using natural tunning (i.e., with
    respect to the harmonic series).
    This method computes intervals with respect to A4.

    Parameters
    ----------
    midi_pitch: int, float or ndarray
        MIDI pitch of the note(s).
    a4 : int or float (optional)
        Frequency of A4 in Hz. By default is 440 Hz.

    Returns
    -------
    freq : float or ndarray
        Frequency of the note(s).

    Notes
    -----
    This implementation computes the natural interval ratios
    (with respect to the harmonic series), but with respect to
    octaves centered on A. All intervals are computed with respect
    to the A in the same octave as the note in question (e.g.,
    C4 is a descending major sixth with respect to A4, E5 is descending
    perfect fourth computed with respect to A5, etc.).

    """

    octave = (midi_pitch // 12) - 1

    aref = 69.0 - 12.0 * (4 - octave)

    aref_freq = a4 / (2.0 ** ((4 - octave)))

    interval = midi_pitch - aref

    if isinstance(interval, (int, float)):
        interval = np.array([interval], dtype=int)

    ratios = np.array(
        [
            natural_interval_ratios[abs(itv)] ** (1 if itv >= 0 else -1)
            for itv in interval
        ]
    )

    freqs = aref_freq * ratios

    if isinstance(midi_pitch, (int, float)):
        freqs = float(freqs)
    return freqs


def midi_pitch_to_tempered_frequency(
    midi_pitch: Union[int, float, np.ndarray],
    reference_midi_pitch: Union[int, float] = 69,
    reference_frequency: float = A4,
    interval_ratios: Dict[int, float] = FIVE_LIMIT_INTERVAL_RATIOS,
) -> Union[float, np.ndarray]:
    """
    Convert MIDI pitch to frequency in Hz using
    a temperament given as frequency ratios above
    a reference pitch.

    Parameters
    ----------
    midi_pitch: int, float or ndarray
        MIDI pitch of the note(s).
    reference_midi_pitch : int or float (optional)
        midi pitch of the reference pitch. By default is 69 (A4).
    reference_frequency : int (optional)
        Frequency of A4 in Hz. By default is 440 Hz.
    interval_ratios: dict
        Dictionary of interval ratios from the reference

    Returns
    -------
    freq : float or ndarray
        Frequency of the note(s).
    """

    interval = (midi_pitch - reference_midi_pitch) % 12
    octave = (midi_pitch - reference_midi_pitch) // 12
    adjusted_reference_frequency = reference_frequency / (2.0**-octave)

    if isinstance(interval, (int, float)):
        interval = np.array([interval], dtype=int)

    ratios = np.array([interval_ratios[abs(itv)] for itv in interval])

    freqs = adjusted_reference_frequency * ratios

    if isinstance(midi_pitch, (int, float)):
        freqs = float(freqs)
    return freqs


def exp_in_exp_out(
    num_frames: int,
    dtype: type = DTYPE,
) -> np.ndarray:
    """
    Sound envelope with exponential attack and decay

    Parameters
    ----------
    num_frames : int
        Size of the window in frames.

    Returns
    -------
    envelope : np.ndarray
        1D array with the envelope.
    """
    # Initialize envelope
    envelope = np.ones(num_frames, dtype=dtype)
    # number of frames for decay
    decay_frames = np.minimum(num_frames // 10, 1000)
    # number of frames for attack
    attack_frames = np.minimum(num_frames // 100, 1000)
    # Compute envelope
    envelope[-decay_frames:] = np.exp(-np.linspace(0, 100, decay_frames)).astype(dtype)
    envelope[:attack_frames] = np.exp(np.linspace(-100, 0, attack_frames)).astype(dtype)

    return envelope


def lin_in_lin_out(num_frames: int, dtype: type = DTYPE) -> np.ndarray:
    """
    Sound envelope with linear attack and decay

    Parameters
    ----------
    num_frames : int
        Size of the window in frames.

    Returns
    -------
    envelope : np.ndarray
        1D array with the envelope.
    """
    # Initialize envelope
    envelope = np.ones(num_frames, dtype=dtype)
    # Number of frames for decay
    decay_frames = np.minimum(num_frames // 10, 1000)
    # number of frames for attack
    attack_frames = np.minimum(num_frames // 100, 1000)
    # Compute envelope
    envelope[-decay_frames:] = np.linspace(1, 0, decay_frames, dtype=dtype)
    envelope[:attack_frames] = np.linspace(0, 1, attack_frames, dtype=dtype)
    return envelope


def additive_synthesis(
    freqs: Union[int, float, np.ndarray],
    duration: float,
    samplerate: Union[int, float] = SAMPLE_RATE,
    weights: Union[int, float, str, np.ndarray] = "equal",
    envelope_fun: Union[str, Callable[[int], np.ndarray]] = "linear",
) -> np.ndarray:
    """
    Additive synthesis for a single note

    Parameters
    ----------
    freqs: Union[int, float, np.ndarray]
        Frequencies of the spectrum of the note.
    duration: float
        Duration of the note in seconds.
    samplerate: int, float
        Sample rate of the note.
    weights: int, float, str or np.ndarray
        Additive weights for the frequencies. If "equal",
        the weights will be 1 / len(freqs) for each frequency.
    envelope_fun: callable, str
        Function for defining the amplitude envelope. If a string,
        it must be "linear" or "exp". See `lin_in_lin_out`
        and `exp_in_exp_out`.

    Returns
    -------
    output: np.ndarray
        The signal corresponding to the note defined by the
        given frequencies, duration, weights and envelop.
        The length of the output is `int(np.round(duration * samplerate))`.
    """
    if isinstance(freqs, (int, float, np.integer, np.floating)):
        freqs = np.array([freqs], dtype=DTYPE)

    if not isinstance(freqs, np.ndarray):
        raise ValueError(
            "`freqs` should be a numpy array, an int or a float "
            f"but is {type(freqs)}"
        )

    if duration < 0 or samplerate < 0:
        raise ValueError(
            "`duration` and `samplerate` must be larger than 0 "
            f"but are {duration}, {samplerate}, respectively"
        )

    if isinstance(weights, (int, float, np.integer, np.floating)):
        weights = np.array([weights], dtype=DTYPE)

    elif isinstance(weights, str):
        if weights == "equal":
            weights = np.ones(len(freqs), dtype=DTYPE) / len(freqs)
        else:
            raise ValueError(
                f"If `weights` is a string, it must be 'equal' but is {weights}"
            )

    if not isinstance(weights, np.ndarray):
        raise ValueError(
            "`weights` should be a numpy array, a number (int or float) or "
            f"a string with value 'equal' but is {type(weights)}"
        )

    if len(freqs) != len(weights):
        raise ValueError("`len(freqs)` should be equal to `len(weights)`")

    freqs = freqs.reshape(-1, 1)
    weights = weights.reshape(-1, 1)

    if isinstance(envelope_fun, str):
        if envelope_fun == "linear":
            envelope_fun = lin_in_lin_out
        elif envelope_fun == "exp":
            envelope_fun = exp_in_exp_out
        else:
            raise ValueError(
                "If `envelope_fun` is a string, it must be 'linear' or 'exp', "
                f"but is {envelope_fun}"
            )

    if not callable(envelope_fun):
        raise ValueError('`envelope_fun` must be "linear", "exp" or a callable')

    num_frames = int(np.round(duration * samplerate))
    envelope = envelope_fun(num_frames)
    x = np.linspace(0, duration, num=num_frames)
    y = weights * np.sin(TWO_PI * freqs * x)

    output = y.sum(0) * envelope

    return output


class DistributedHarmonics(object):
    def __init__(
        self,
        n_harmonics: int,
        weights: Union[np.ndarray, str] = "equal",
    ) -> None:
        self.n_harmonics = n_harmonics
        self.weights = weights

        if self.weights == "equal":
            self.weights = 1.0 / (self.n_harmonics + 1) * np.ones(self.n_harmonics + 1)

        self._overtones = np.arange(1, self.n_harmonics + 2)

    def __call__(self, freq: float) -> Tuple[np.ndarray]:
        return self._overtones * freq, self.weights


class ShepardTones(object):
    """
    Generate Shepard Tones
    """

    def __init__(
        self,
        min_freq: Union[float, int] = 77.8,
        max_freq: Union[float, int] = 2349,
    ) -> None:
        self.min_freq = min_freq
        self.max_freq = max_freq

        x_freq = np.linspace(self.min_freq, self.max_freq, 1000)

        weights = np.hanning(len(x_freq) + 2) + 0.001
        weights /= max(weights)

        self.shepard_weights_fun = interp1d(
            x=x_freq,
            y=weights[1:-1],
            bounds_error=False,
            fill_value=weights.min(),
        )

    def __call__(self, freq) -> Tuple[np.ndarray]:
        min_freq = self.min_f(freq)

        freqs = 2 ** np.arange(5) * min_freq

        return freqs, self.shepard_weights_fun(freqs)

    def min_f(self, freq: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
        n = np.floor(np.log2(freq) - np.log2(self.min_freq))
        return freq / (2**n)

    def max_f(self, freq: Union[float, np.ndarray]) -> Union[float, np.ndarray]:
        n = np.floor(np.log2(self.max_freq) - np.log2(freq))
        return freq * (2**n)


[docs]def synthesize( note_info, samplerate: int = SAMPLE_RATE, envelope_fun: str = "linear", tuning: Union[str, Callable] = "equal_temperament", tuning_kwargs: Dict[str, Any] = {"a4": A4}, harmonic_dist: Optional[Union[str, int]] = None, bpm: Union[float, np.ndarray, Callable] = 60, ) -> np.ndarray: """ Synthesize a partitura object with note information using additive synthesis Parameters ---------- note_info : ScoreLike, PerformanceLike or np.ndarray A partitura object with note information. samplerate: int The sample rate of the audio file in Hz. envelope_fun: {"linear", "exp" } The type of envelop to apply to the individual sine waves. tuning: {"equal_temperament", "natural"} or callable. The tuning system to use. If the value is "equal_temperament", 12 tone equal temperament implemented in `midi_pitch_to_frequency` will be used. If the value is "natural", the function `midi_pitch_to_tempered_frequency` will be used. Note that `midi_pitch_to_tempered_frequency` computes the intervals (and thus, frequencies) with respect to a reference note (A4 by default) and uses the interval ratios specified by `FIVE_LIMIT_INTERVAL_RATIOS`. See the documentation of these functions for more information. If a callable is provided, function should get MIDI pitch as input and return frequency in Hz as output. tuning_kwargs : dict Dictionary of keyword arguments to be passed to the tuning function specified in `tuning`. See `midi_pitch_to_tempered_frequency` and `midi_pitch_to_frequency` for more information on their keyword arguments. harmonic_dist : int, "shepard" or None (optional) Distribution of harmonics. If an integer, it is the number of harmonics to be considered. If "shepard", it uses Shepard tones. Default is None (i.e., only consider the fundamental frequency) bpm : float, np.ndarray or callable The bpm to render the output (if the input is a score-like object). See `partitura.utils.music.performance_notearray_from_score_notearray` for more information on this parameter. Returns ------- audio_signal : np.ndarray Audio signal as a 1D array. """ note_array = ensure_notearray(note_info) onset_unit, duration_unit = get_time_units_from_note_array(note_array) if np.min(note_array[onset_unit]) <= 0: note_array[onset_unit] = note_array[onset_unit] + np.min(note_array[onset_unit]) # If the input is a score, convert score time to seconds if onset_unit != "onset_sec": pnote_array = performance_notearray_from_score_notearray( snote_array=note_array, bpm=bpm, ) onsets = pnote_array["onset_sec"] offsets = pnote_array["onset_sec"] + pnote_array["duration_sec"] duration = pnote_array["duration_sec"] else: onsets = note_array["onset_sec"] offsets = note_array["onset_sec"] + note_array["duration_sec"] duration = note_array["duration_sec"] pitch = note_array["pitch"] # Duration of the piece piece_duration = offsets.max() # Number of frames num_frames = int(np.round(piece_duration * samplerate)) # Initialize array containing audio audio_signal = np.zeros(num_frames, dtype="float") # Initialize the time axis x = np.linspace(0, piece_duration, num=num_frames) # onsets in frames (i.e., indices of the `audio` array) onsets_in_frames = np.searchsorted(x, onsets, side="left") # frequency of the note in herz if tuning == "equal_temperament": freq_in_hz = midi_pitch_to_frequency(pitch, **tuning_kwargs) elif tuning == "natural": freq_in_hz = midi_pitch_to_tempered_frequency(pitch, **tuning_kwargs) elif callable(tuning): freq_in_hz = tuning(pitch, **tuning_kwargs) else: raise ValueError( "`tuning` must be 'equal_temperament', 'natural' or a callable" ) if harmonic_dist is None: def harmonic_dist(x): return x, 1 elif isinstance(harmonic_dist, int): harmonic_dist = DistributedHarmonics(harmonic_dist) elif isinstance(harmonic_dist, str): if harmonic_dist in ("shepard",): harmonic_dist = ShepardTones() for f, oif, dur in zip(freq_in_hz, onsets_in_frames, duration): freqs, weights = harmonic_dist(f) note = additive_synthesis( freqs=freqs, duration=dur, samplerate=samplerate, weights=weights, envelope_fun=envelope_fun, ) idx = slice(oif, oif + len(note)) audio_signal[idx] += note # normalization term # TODO: Non-linear normalization? norm_term = max(audio_signal.max(), abs(audio_signal.min())) # normalize audio audio_signal /= norm_term return audio_signal