Tutorial¶
There is one idea to absorb, and the rest follows from it: every routine in this package returns a numpy array of PCM samples. A note is an array. An envelope is an array. A whole piece is an array. Nothing is a handle to an audio engine, nothing has to be started or torn down, and there is no graph to wire up. You make arrays, you combine them with arithmetic, and at the end you write one to a file.
This page walks from a single note to a short stereo piece. Every block below runs as written.
A first sound¶
import music
a = music.note(freq=440, duration=1.5)
music.write_wav_mono(a, "first.wav")
a is 66,150 float64 samples — 1.5 seconds at 44.1 kHz — in the range
[-1, 1]. Frequency is in hertz, duration in seconds. To hear it without
writing a file, use music.play_audio().
Should you prefer to think in samples rather than seconds, every synthesis
routine takes number_of_samples instead, and it wins over duration:
exactly_one_period = music.note(440, number_of_samples=round(44100 / 440))
It is all numpy¶
Because the return value is an ordinary array, the operations you already know are the mixing desk:
quiet = music.note(440, 1) * 0.5 # halve the amplitude
inverted = -music.note(440, 1) # flip the phase
both = music.note(440, 1) + music.note(660, 1) # sum two tones
The package’s own helpers exist for the cases where plain arithmetic is
not enough — where lengths differ, or where a stereo array has to be kept
distinct from a mono one. music.mix() sums two vectors, padding the
shorter; music.horizontal_stack() concatenates them end to end.
Notes in sequence¶
A melody is notes concatenated:
freqs = [261.63, 293.66, 329.63, 349.23, 392.0] # C D E F G
notes = [music.note(f, 0.35) for f in freqs]
melody = music.horizontal_stack(*[music.adsr(sonic_vector=n)
for n in notes])
Each note is shaped before the notes are stacked, and that is not a
flourish. A note ends wherever its phase happens to land, and the next one
opens at the bottom of its wavetable, so two raw notes joined end to end
step from one to the other – by up to the full scale, which is a click.
Whether it happens at all depends on the frequency and the duration
multiplying out to a whole number of cycles, so a melody can click on some
notes and not others, which makes it sound like an articulation rather
than a defect. music.adsr(), the next section, runs each note to
silence at both ends and the joins go quiet. tests/test_artifacts.py
measures both.
If you would rather write rhythm in beat units than in seconds,
music.rhythm_to_durations() converts them. Its durations are
relative counts of the beat unit. With bpm=120, one unit lasts half a
second:
>>> music.rhythm_to_durations([1, 2, 2, 1], bpm=120)
[0.5, 1.0, 1.0, 0.5]
Pass duration instead when you already know the duration of one beat.
Shaping a note¶
A bare note starts and stops abruptly, which is audible as a click. An ADSR envelope fixes that and is most of what makes a sound feel like an instrument:
raw = music.note(440, 1)
shaped = music.adsr(sonic_vector=raw, attack_duration=50,
decay_duration=100, sustain_level=-9,
release_duration=300)
shaped is the same length as raw: the envelope is fitted to the
sound it is given, not appended to it. Called without a sonic_vector,
the same function returns the bare envelope, which you can inspect, plot,
or multiply into something yourself.
Note the units. They are not uniform, and deliberately so — each parameter is expressed in the unit that parameter is normally thought about in:
Kind of quantity |
Unit |
Examples |
|---|---|---|
Duration of a sound |
seconds |
|
Duration of an envelope stage |
milliseconds |
|
Level |
decibels |
|
Pitch deviation |
semitones |
|
Frequency |
hertz |
|
Angle |
degrees |
|
The other shapers follow the same convention — pass sonic_vector to
apply, omit it to get the envelope. music.fade() for a fade in or
out, music.tremolo() for periodic loudness, music.am() for
amplitude modulation, music.loud() for a single loudness transition,
music.reverb() for a room.
What “sample by sample” buys you¶
This is the claim the package is built on, and it is worth seeing rather than taking on trust. A vibrato is a periodic deviation in pitch. The common way to build one is to render a steady tone and modulate it afterwards; this package instead folds the deviation into the wavetable lookup, accumulating phase sample by sample, so the note genuinely has a different instantaneous frequency at every one of its samples.
You can measure that from the output alone. Render two semitones of vibrato around 440 Hz, then recover the frequency cycle by cycle from the interpolated zero crossings:
import numpy as np
v = music.note_with_vibrato(freq=440, duration=2, vibrato_freq=5,
max_pitch_dev=2)
negative = np.signbit(v)
crossings = np.where(~negative[1:] & negative[:-1])[0]
exact = crossings + v[crossings] / (v[crossings] - v[crossings + 1])
inst = 44100 / np.diff(exact)
print(f"{inst.min():.1f} .. {inst.max():.1f} Hz")
which prints 392.0 .. 493.9 Hz. The model says the extremes should be
440·2±2/12, which is 392.0 and 493.9 Hz. The rendered audio
agrees to the last digit shown, and takes 617 distinct values along the
way — it is not stepping between a handful of pitches, it is a continuous
curve sampled at the audio rate.
The same is true of a glissando. An exponential sweep from 220 Hz to 880 Hz should pass through the geometric mean at its midpoint:
g = music.note_with_glissando(220, 880, duration=2, method="exp")
Measured at the halfway sample, it is at 441 Hz; the geometric mean of 220 and 880 is 440.
music.note_with_fm() (frequency modulation) and
music.note_with_doppler() (a source moving past the listener, which
produces the pitch shift and the stereo image) are built the same way.
Layering¶
music.mix() sums two vectors and pads the shorter one, so a chord is
a fold:
chord = music.note(261.63, 2)
for f in (329.63, 392.0):
chord = music.mix(chord, music.note(f, 2))
music.mix_with_offset() starts the second sound a given number of
seconds after the first, which is enough for an arpeggio or a canon:
canon = music.mix_with_offset(melody, melody, duration=0.7)
A negative duration places the second sound that many seconds before
the first one ends.
Scales, chords and intervals¶
music.theory counts everything in semitones from a tonic of zero,
which is what music.pitch_to_freq() takes, so a scale or a chord
becomes frequencies, and then sound, in two steps:
dorian = music.pitch_to_freq(220.0, music.scale("dorian"))
ascent = music.horizontal_stack(
*[music.adsr(sonic_vector=music.note(f, 0.3)) for f in dorian])
seventh = music.pitch_to_freq(220.0, music.chord("minor seventh"))
chord = music.mix_many([music.note(f, 1.5) for f in seventh])
The seven modes are one step pattern read from seven places, which is how
music.mode_by_rotation() reaches them, and intervals are written the
way a musician writes them:
>>> music.scale("dorian"), music.chord("minor seventh")
((0, 2, 3, 5, 7, 9, 10), (0, 3, 7, 10))
>>> music.interval("M3"), music.interval("P11")
(4, 17)
>>> music.interval_names(6)
('aug4', 'dim5', 'TT')
music.harmonic_series() gives the partials over a fundamental, where
the octaves land on the tempered scale and nothing else does.
The sequencer¶
Once there are more than a handful of overlapping events, give them absolute start times instead:
seq = music.Sequencer()
for i, freq in enumerate([440, 550, 660]):
seq.add_note(freq, start=i * 0.25, duration=1.0,
adsr_params={"attack_duration": 20,
"release_duration": 400})
seq.write("chord.wav")
seq.render() returns the array instead of writing it, so the result
goes back into everything above.
Stereo, and where a sound is¶
A stereo signal is a (2, n) array — channel first. The localization
routines take a mono vector and return one of these, computing the
interaural time and intensity differences from a geometry you describe.
music.localize() fixes the source at an angle and distance:
fixed = music.localize(music.note(440, 1), theta=45, distance=2)
Angles are measured from the ear axis: 0 is the right ear’s side, 90
straight ahead and 180 the left. music.localize2() is the exception,
measuring from straight ahead, as its documentation says.
music.localize_linear() moves it in a straight line, recomputing
the position — and from it the two delays and the two gains — at every
sample:
passing = music.localize_linear(music.note(330, 3),
theta1=150, theta2=30, dist=0.6)
music.write_wav_stereo(passing, "passing.wav", fades=(100, 500))
Angles are in degrees, and x is the lateral axis: 0° is the right ear, 90° is straight ahead, 180° is the left ear. So the call above sweeps from left to right.
The delays are fractional — a source 0.3 samples further from one ear than the other is rendered as 0.3 of a sample, by cubic interpolation, not rounded to the nearest one. At 44.1 kHz a whole sample is already 8 mm of head, so rounding would be audible as a coarse, stepping image.
fades above is a shorthand every writer takes: (in, out) in
milliseconds, applied on the way out to the file.
Noise¶
Six colours, each defined by its gain per octave — brown at −6 dB, pink at −3, white at 0, blue at +3, violet at +6, black at −12:
colours = music.horizontal_stack(
*[music.noise(kind, 0.5)
for kind in ("brown", "pink", "white", "blue", "violet")])
Any number works in place of a name, and means exactly that many decibels
per octave, so music.noise(-1.5, 0.5) is halfway between pink and
white. Noise is also what music.reverb() convolves with to make a
tail.
For a response you design yourself, music.fir() takes a magnitude
spectrum — one absolute value per frequency bin, the last being Nyquist —
and applies it:
dull = music.fir(np.linspace(1, 0, 64), music.noise("white", 0.5))
Filters¶
music.iir() applies a filter given its coefficients, and
music.low_pass(), music.high_pass(), music.band_pass()
and music.band_reject() compute them, from the four designs the
article specifies. A cutoff is a fraction of the sample rate, which
music.fraction_of() converts a frequency in Hertz into, so the same
coefficients make the same filter at any rate:
a, b = music.low_pass(music.fraction_of(1000))
muffled = music.iir(music.noise("white", 2), a, b)
Noise is the thing to hear a filter on, having every frequency there is for it to take away.
Structure: change ringing as melody¶
The part of the package with the least competition elsewhere is the
music.structures module: permutation groups, and the peals of
English change ringing. A peal is a sequence of permutations in which each
step swaps only adjacent pairs, and which visits every permutation exactly
once. Applied to bells it is campanology; applied to frequencies the
peal is the melody, and its symmetry is audible:
peal = music.PlainChanges(4)
rows = peal.act([220, 275, 330, 440])
notes = [music.note(freq, 0.2) for row in rows for freq in row]
music.write_wav_mono(music.horizontal_stack(*notes), "campanology.wav")
rows holds all 24 permutations of the four frequencies, in ringing
order — [220, 275, 330, 440], [275, 220, 330, 440],
[275, 330, 220, 440], and so on. act will permute any sequence you
hand it, so the same structure can order durations, amplitudes, or stereo
positions rather than pitches.
music.InterestingPermutations gives the other groups — rotations,
reflections, alternating and dihedral subgroups — with the same interface.
Sensory stimulation¶
music.stimulation renders the stimuli of sensory-stimulation work,
each named for the technique it implements in SSTIM, the Sensory Stimulation Vocabulary. A binaural
beat is two steady tones a few hertz apart, one in each ear: the beat is
in the listener rather than in the file, and is heard over headphones:
beat = music.binaural_beats(carrier_freq=220, beat_freq=7, duration=8)
music.write_wav_stereo(beat, "binaural.wav")
Such stimuli are delivered as protocols rather than one at a time, and
music.StimulationSession renders one: phases in order, crossfaded
rather than cut, lasting exactly the sum of their durations:
session = music.StimulationSession(end_ramp=2.0)
session.add(music.binaural_beats, duration=8, ramp=1.0,
carrier_freq=220, beat_freq=7)
session.add(music.isochronic_tones, duration=8, ramp=2.0,
carrier_freq=220, pulse_rate=4.0, ramp_duration=0.005)
session.write("session.wav")
Monaural beats, isochronic tones, amplitude and frequency modulation,
modulated noise and a source circling the head are the others, and the
sensory_stimulation example writes one of each.
Singing¶
music.sing() sings a lyric to a melody, one syllable a note. The
syllables of a word are joined by hyphens and the words parted by spaces;
each note is a pitch, in semitones above reference, middle C, and a
duration, in units of L at the tempo Q, as ABC notation writes
them: a quarter note, at 120 a minute, by default.
twinkle = music.sing(text="Twin-kle, twin-kle, lit-tle star",
notes=(0, 0, 7, 7, 9, 9, 7),
durs=(1, 1, 1, 1, 1, 1, 2))
music.write_wav_mono(twinkle, "twinkle.wav")
The singer is the package’s own. espeak-ng says each syllable, and
Praat’s pitch-synchronous overlap-add, PSOLA, holds it at its note’s pitch
and makes it the note’s length. A syllable said in less time than its note
is said again, slower; then only the middle of its vowel is lengthened, so
that its consonants keep the length they were said with, and a long note
is sung with a vibrato. The notes on the strong beats of the meter, M,
are sung a little louder. It needs espeak-ng (sudo apt install
espeak-ng, or brew install espeak-ng) and pip install
'music[singing]', and says which is missing.
lang names the espeak voice, and with it the language the lyric is
sung in, and effect sings with one of three voices: "tremolo",
which trembles and rings in a room, in stereo; "melt", the same with a
voice whose formants sink with the pitch; and "flite", flite’s voice
in espeak’s place.
frere = music.sing(text="Frè-re Jac-ques, dor-mez vous?",
notes=(0, 2, 4, 0, 4, 5, 7),
durs=(1, 1, 1, 1, 1, 1, 2),
lang="fr", effect="tremolo")
music.write_wav_stereo(frere, "frere.wav")
backend="ecantorix" sings the same score with eCantorix, the engine the package sang with
before it had its own, and keeps as the reference: a Perl program, which
music.setup_engine() clones once, and which needs Perl and four of
its modules, abc2midi and sox. The two sing the same pitches for the same
lengths, so what differs is the singing, and the examples
singing_backends.py and singing_effects.py sing songs in three
languages, and the effects, with each, side by side.
Putting it together¶
Nothing here is new; it is the pieces above composed:
import music
import numpy as np
# a melodic line, each note given a vibrato and an envelope
voice = music.horizontal_stack(*[
music.adsr(sonic_vector=music.note_with_vibrato(
freq, 0.5, vibrato_freq=6, max_pitch_dev=0.3),
sustain_level=-6)
for freq in (392, 440, 493.88, 587.33)])
# a quiet bed of pink noise underneath it, of exactly the same length
bed = music.loud(sonic_vector=music.noise("pink",
len(voice) / 44100)) * 0.05
# mix them, then sweep the result across the stereo field
piece = music.localize_linear(music.mix(voice, bed),
theta1=120, theta2=60)
music.write_wav_stereo(piece, "piece.wav", fades=(100, 500))
Where to go next¶
The API reference reference. Every routine’s docstring carries the equation it implements, an example, and the article section it comes from.
The examples folder, which holds each of the above as a runnable script, plus the
BeingandIteratorSynthclasses for generating material algorithmically, and the two singers singing the same songs side by side.What this page leaves out:
music.setup_hrtf()andmusic.hrir(), which place a source with measured ears, above or behind you, wheremusic.localize()can only place it to a side; andmusic.Bonds, which ties a note’s vibrato and tremolo to its pitch, once, for a whole piece.Musical elements in the discrete-time representation of sound, the article the whole package implements. If you use this package, please cite it.
If
musicis useful to you or your institution, sponsoring its development helps keep it maintained and open. Commissioned features and sponsored development are available too.