blip8
Chiptune sound synthesis from code: the four voices of the NES and the Game Boy, generated from scratch. No samples, no recordings, no dependencies beyond NumPy. Every sound is arithmetic.
pip install blip8 # or: uv add blip8
from blip8 import melody, play, sfx
play(melody("E4 E4 F4 G4 G4 F4 E4 D4", bpm=140)) # a tune, in one line
play(sfx.coin())
play(sfx.kick() + sfx.hat()) # adding arrays mixes them
Or build sounds yourself from the raw waveforms:
from blip8 import envelope, noise, play, square, triangle
play(square(freq=440, length=0.5)) # 1983 in one line
play(square(freq=440, length=0.5, duty=0.125)) # same note, thinner (the NES knob)
play(square(freq=440, length=0.5) + triangle(freq=220, length=0.5)) # melody + bass
play(square(freq=(1800, 200), length=0.25)) # a (start, end) pitch = laser
play(envelope(noise(length=1.5), decay=1.5, sustain=0.0)) # noise + fade = cymbal
Why I made this
Every time I built a game I ended up writing the same throwaway script again: forty lines of NumPy to make a coin sound, pasted into the repo, tweaked until it was close enough, then forgotten. The next game started from zero.
So I wrote the library I should have written the first time. Now it is
pip install blip8 and sfx.coin().
It is also my first real Python project, coming from TypeScript and Go, and an excuse to explore how synthesis actually works. The NES had four voices and no way to play a recording, which makes it a small enough system to build from first principles and understand completely.
Hear it
uv run examples/first_beep.py # two square waves, and the duty knob
uv run examples/four_voices.py # square vs triangle vs noise
uv run examples/sound_design.py # snare, crash, kick, laser, power-up, coin
uv run examples/sfx_menu.py # every recipe, the wave channel, a drum groove
uv run examples/melody.py # actual music: four voices, chords vs arpeggios
uv run examples/see_it.py # print the waveforms instead of playing them
uv sets up Python and installs NumPy on first run, so there is nothing else
to do. save() writes a .wav anywhere. play() shells out to whichever
player the platform has (afplay on macOS, aplay, paplay or ffplay on
Linux, winsound on Windows) and raises a clear error if none is present.
The voices
The NES sound chip had four, each locked to one waveform. The Game Boy's had four too, with a twist.
| Voice | Shape | Sounds like | Used for | Status |
|---|---|---|---|---|
| Pulse | square | buzzy, electronic | melody, harmony | ✅ square() |
| Triangle | ramp up/down | soft, flute-ish | basslines | ✅ triangle() |
| Noise | random | static | drums, explosions | ✅ noise() |
| Wave | anything you draw | whatever you make it | the Game Boy's trick | ✅ wavetable() |
Square waves have one knob, duty, which is how much of each cycle is spent
"up". 0.5 sounds round and hollow, 0.125 thin and nasal. Same pitch,
different character. That knob is most of the NES's personality.
Shaping
The waveforms are the raw material. Shaping them over time is what makes them recognisable as things.
envelope() controls volume over time, the ADSR curve every synth has.
It's why a drum and a piano playing the same note are unmistakable. It also
stops notes ending in a click, since they now finish at silence instead of
mid-cycle.
Pitch sweeps come free: pass freq=(start, end) instead of one number and
the note glides. Falling is a laser, rising is a power-up.
Between them, the same noise() becomes a snare (fast fade) or a cymbal
(slow fade), and a triangle() sliding from 120 Hz to 40 Hz becomes a kick
drum. examples/sound_design.py plays all of it.
crunch() throws precision away on purpose. bits=4 allows only 16 volume
levels, which is what the Game Boy actually had. Producers call this
bitcrushing and reach for it deliberately.
Recipes
sfx is the cookbook: 14 game sounds with the numbers already chosen.
sfx.blip() sfx.select() sfx.back() sfx.coin()
sfx.jump() sfx.powerup() sfx.laser() sfx.hurt()
sfx.explosion() sfx.chime()
sfx.kick() sfx.snare() sfx.hat() sfx.crash()
Each returns an array rather than playing it, so they mix (+), chain, and
save(). Every one is built only from the waveforms and shapers above. Open
src/blip8/sfx.py to see exactly what any given sound is made of.
Music
Nobody thinks in Hz, so note("E5") converts names to frequencies. An octave
up doubles the frequency, and a semitone is the twelfth root of two.
Patterns are strings, one token per step in time. A note name plays, . holds
the note before it, - rests:
melody("C4 E4 G4 C5", bpm=120) # four sixteenth notes
melody("C2 . . . G2 . . .", voice=triangle) # a bassline, notes held
melody("C5 - C5 -", duty=0.125) # extra kwargs reach the voice
That's a tracker, the text-grid format chiptune musicians actually use, arrived at because it's the obvious way to write music as a string.
chord("C4 E4 G4") plays notes together; arpeggio("C4 E4 G4") plays them one
at a time, fast, until your ear fuses them into a chord. The second one exists
because the NES only had four voices and couldn't spare three for one chord,
which is why chiptune has that frantic bubbling sound.
Arrangement is two functions. at(time, sound) delays; layer(*sounds) mixes,
padding to the longest:
layer(at(0.0, sfx.kick()), at(0.5, sfx.snare()))
See it
GitHub can't play audio, so show() draws the waveform in your terminal:
min/max per column, the way an audio editor does. Zoom in on a few cycles to
see the shape, or out to a whole sound to see its envelope.
from blip8 import show, square
show(square(freq=440, length=0.007), "three cycles")
square, zoomed in on three cycles
███████████ ███████████ ███████████ ██
█ █ █ █ █ █
█ █ █ █ █ █
█ █ █ █ █ █
█ █ █ █ █ █
──────────█─────────█─────────█─────────█─────────█───────────█─
█ █ █ █ █ █
█ █ █ █ █ █
█ █ █ █ █ █
█ █ █ █ █ █
███████████ ███████████ █████████████
triangle, the same three cycles
█ ██ ██ ██
██ ████ ████ ██ █
██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██
────██────────██────────██────────██────────██─────────██───────
██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██
██ ██ ██ ██ ██ ██
████ ████ ████
██ ██ ██
a cymbal crash, zoomed out to 1.2 seconds
███
█████████████████
███████████████████████████████
████████████████████████████████████████████
██████████████████████████████████████████████████████████
████████████████████████████████████████████████████████████████
██████████████████████████████████████████████████████████
████████████████████████████████████████████
██████████████████████████████
█████████████████
███
uv run examples/see_it.py prints all four voices, the duty knob, envelopes,
sweeps and bit crushing.
Develop
uv run pytest # 241 tests, all asserting things you can hear
uv run mypy src tests # type check, strict
uv run ruff check . # lint
uv run ruff format . # format
CI runs all four on Linux, macOS and Windows across Python 3.12 and 3.13, then builds the package and installs the wheel into a clean environment to prove it imports.
The package ships py.typed, so the annotations reach your editor rather than
stopping at the package boundary. Samples and Pitch are exported for
annotating your own code.
The test suite is worth a read if you want to know how you test a sound: every claim in the docstrings turns out to be a claim about numbers.
Releases go out from a version tag through PyPI Trusted Publishing, so no API token exists to leak. Design decisions with real tradeoffs are recorded in docs/decisions.
Status
All four voices, envelopes, sweeps, bit crushing, 14 ready-made game sounds, note names, patterns, chords, arpeggios and arrangement. It makes music now.
The roadmap ends with blip8 cover song.mid turning any MIDI file into an
8-bit cover.
Release files for blip8 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| blip8-0.1.0.tar.gz | 50.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| blip8-0.1.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 65.6 kB
Release files / blip8-0.1.0.tar.gz
| Download URL | blip8-0.1.0.tar.gz |
|---|---|
| Size | 50.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
9fda01c3a467c7b1c8cd6e586e2eec569101c990f9382998c10088494d62f209
|
|
BLAKE2b-256 checksum How to use checksums |
8e65f20967c9433d004e4924ec770dad79b14e0e07e107ff4c69bc4b6d53b812
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency logRelease files / blip8-0.1.0-py3-none-any.whl
| Download URL | blip8-0.1.0-py3-none-any.whl |
|---|---|
| Size | 15.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
be6e80a333e74923bcceea83fff975330c3c9d962081404de2adca7bdba006d3
|
|
BLAKE2b-256 checksum How to use checksums |
afa959d69a3c0106748ffa6d028c14fd7f03adc80386dc547637e7211b8ac565
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Aug 4, 2026.
Transparency log