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
melody = music.horizontal_stack(*[music.note(f, 0.35) for f in freqs])
If you would rather write rhythm in note values than in seconds,
music.rhythm_to_durations() converts them. Its durations are
denominators of a whole note, so 4 is a quarter note:
>>> music.rhythm_to_durations([4, 2, 2, 4], duration=0.25)
[1.0, 0.5, 0.5, 1.0]
Pass bpm instead of duration to give the tempo directly.
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.
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)
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))
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.
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.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.