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.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file blip8-0.1.0.tar.gz.
File metadata
- Download URL: blip8-0.1.0.tar.gz
- Upload date:
- Size: 50.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
9fda01c3a467c7b1c8cd6e586e2eec569101c990f9382998c10088494d62f209
|
|
| MD5 |
c27197a1af81079e10c8660de7cbbdca
|
|
| BLAKE2b-256 |
8e65f20967c9433d004e4924ec770dad79b14e0e07e107ff4c69bc4b6d53b812
|
Provenance
The following attestation bundles were made for blip8-0.1.0.tar.gz:
Publisher:
release.yml on sindriax/blip8
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blip8-0.1.0.tar.gz -
Subject digest:
9fda01c3a467c7b1c8cd6e586e2eec569101c990f9382998c10088494d62f209 - Sigstore transparency entry: 2340515106
- Sigstore integration time:
-
Permalink:
sindriax/blip8@6f1641ce07d415884e49b8aa3dfefb708817e607 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sindriax
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6f1641ce07d415884e49b8aa3dfefb708817e607 -
Trigger Event:
push
-
Statement type:
File details
Details for the file blip8-0.1.0-py3-none-any.whl.
File metadata
- Download URL: blip8-0.1.0-py3-none-any.whl
- Upload date:
- Size: 15.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
be6e80a333e74923bcceea83fff975330c3c9d962081404de2adca7bdba006d3
|
|
| MD5 |
8ef9495aec0bc4c67d822052025fe241
|
|
| BLAKE2b-256 |
afa959d69a3c0106748ffa6d028c14fd7f03adc80386dc547637e7211b8ac565
|
Provenance
The following attestation bundles were made for blip8-0.1.0-py3-none-any.whl:
Publisher:
release.yml on sindriax/blip8
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
blip8-0.1.0-py3-none-any.whl -
Subject digest:
be6e80a333e74923bcceea83fff975330c3c9d962081404de2adca7bdba006d3 - Sigstore transparency entry: 2340515119
- Sigstore integration time:
-
Permalink:
sindriax/blip8@6f1641ce07d415884e49b8aa3dfefb708817e607 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/sindriax
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@6f1641ce07d415884e49b8aa3dfefb708817e607 -
Trigger Event:
push
-
Statement type: