bitcomposer 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """bitcomposer — Procedural 16-bit chiptune music generator."""
2
+
3
+ __version__ = "1.0.0rc1"
4
+
5
+ # The registry first, then the packs that register their voices into it.
6
+ from . import samples, packs # noqa: E402,F401
@@ -0,0 +1,5 @@
1
+ """Allow `python3 -m bitcomposer` as a synonym for `python3 -m bitcomposer.cli`."""
2
+
3
+ from .cli import main
4
+
5
+ main()
bitcomposer/arp.py ADDED
@@ -0,0 +1,192 @@
1
+ """
2
+ Arpeggio figures.
3
+
4
+ The arp used to be a direction — up, down, up-down or random — stepping
5
+ through the chord tones every eighth note for the whole song, one setting
6
+ per song, with random velocity jitter. That reads as a sequencer running in
7
+ the background. Game arps are *figures*: short rhythmic shapes with accents
8
+ and rests that recur with the section, climb, and leave room for the lead.
9
+
10
+ A figure is one bar of (row, degree, accent) steps. `degree` indexes a
11
+ ladder of chord tones that starts on the chord's root in octave 4 and
12
+ climbs through the chord — so 0 is the root, 1 the next chord tone up, and
13
+ for a triad 3 is the root an octave higher. Negative degrees reach below.
14
+ Steps are 16 rows to the bar; a figure can be sparser than that.
15
+ """
16
+
17
+ import random
18
+
19
+ from .pattern import ROWS_PER_BAR
20
+
21
+ # Accent levels, as a fraction of the section's arp volume.
22
+ S, N, W = 1.0, 0.8, 0.62 # strong, normal, weak
23
+
24
+ FIGURES = {
25
+ # name: {"steps": [(row, degree, accent), ...], "energy": (lo, hi), "climb": bool}
26
+ "sparse_pulse": {
27
+ "steps": [(0, 0, S), (8, 3, N)],
28
+ "energy": (0.0, 0.55), "climb": False},
29
+ "quarter_up": {
30
+ "steps": [(0, 0, S), (4, 1, W), (8, 2, N), (12, 3, W)],
31
+ "energy": (0.0, 0.7), "climb": False},
32
+ "classic_up": {
33
+ "steps": [(0, 0, S), (2, 1, W), (4, 2, N), (6, 3, W),
34
+ (8, 0, S), (10, 1, W), (12, 2, N), (14, 3, W)],
35
+ "energy": (0.4, 1.0), "climb": True},
36
+ "up_down": {
37
+ "steps": [(0, 0, S), (2, 1, W), (4, 2, N), (6, 3, W),
38
+ (8, 2, S), (10, 1, W), (12, 0, N), (14, 1, W)],
39
+ "energy": (0.4, 1.0), "climb": False},
40
+ "broken_octave": { # 1-5-8-5, the Mega Man figure
41
+ "steps": [(0, 0, S), (2, 2, W), (4, 3, N), (6, 2, W),
42
+ (8, 0, S), (10, 2, W), (12, 3, N), (14, 2, W)],
43
+ "energy": (0.5, 1.0), "climb": True},
44
+ "pedal_bounce": { # root pedal against the chord above it
45
+ "steps": [(0, 0, S), (2, 3, W), (4, 0, N), (6, 4, W),
46
+ (8, 0, S), (10, 3, W), (12, 0, N), (14, 5, W)],
47
+ "energy": (0.5, 1.0), "climb": False},
48
+ "sync_push": { # dotted-eighth push
49
+ "steps": [(0, 0, S), (3, 2, W), (6, 3, N), (8, 0, S), (11, 2, W), (14, 3, N)],
50
+ "energy": (0.5, 1.0), "climb": True},
51
+ "offbeat_stabs": { # chord tones on the "and"s, bass has the beat
52
+ "steps": [(2, 3, N), (6, 2, N), (10, 3, S), (14, 4, N)],
53
+ "energy": (0.5, 1.0), "climb": False},
54
+ "gallop": {
55
+ "steps": [(0, 0, S), (3, 0, W), (4, 2, N), (8, 0, S), (11, 0, W), (12, 3, N)],
56
+ "energy": (0.7, 1.0), "climb": False},
57
+ "run16": { # sixteenth-note run over two octaves
58
+ "steps": [(r, d, (S if r % 8 == 0 else (N if r % 4 == 0 else W)))
59
+ for r, d in zip(range(16), [0, 1, 2, 3, 4, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4, 5])],
60
+ "energy": (0.8, 1.0), "climb": False},
61
+ "cascade": { # falling from the top of the chord
62
+ "steps": [(0, 5, S), (2, 4, W), (4, 3, N), (6, 2, W),
63
+ (8, 5, S), (10, 4, W), (12, 3, N), (14, 1, W)],
64
+ "energy": (0.5, 1.0), "climb": False},
65
+ }
66
+
67
+
68
+ def pick_figures(energy_pref: str, weights: dict[str, float] | None = None) -> dict[str, str]:
69
+ """Choose a figure per section role for the song.
70
+
71
+ Verse and chorus always differ, so the chorus arp is part of the hook
72
+ rather than the same loop as everything else. The bridge gets a third
73
+ figure; intros and outros use the sparse pulse. A style pack may pass
74
+ weights over figure names; figures it weights at zero are out and the
75
+ chorus draw is weighted.
76
+ """
77
+ if weights:
78
+ pool = [f for f in FIGURES if weights.get(f, 0) > 0]
79
+ chorus_pool = [f for f in pool if FIGURES[f]["energy"][0] >= 0.4] or pool
80
+ chorus = random.choices(chorus_pool, [weights[f] for f in chorus_pool], k=1)[0]
81
+ rest = [f for f in pool if f != chorus]
82
+ random.shuffle(rest)
83
+ return {"verse": rest[0], "chorus": chorus, "bridge": rest[1] if len(rest) > 1 else rest[0],
84
+ "intro": "sparse_pulse", "outro": "quarter_up"}
85
+ if energy_pref == "chill":
86
+ pool = ["quarter_up", "classic_up", "up_down", "pedal_bounce", "sparse_pulse"]
87
+ elif energy_pref == "intense":
88
+ pool = ["classic_up", "broken_octave", "sync_push", "gallop", "run16",
89
+ "offbeat_stabs", "cascade"]
90
+ else:
91
+ pool = ["classic_up", "up_down", "broken_octave", "pedal_bounce", "sync_push",
92
+ "offbeat_stabs", "cascade", "quarter_up"]
93
+ # The chorus runs at the top of the energy curve, so its figure must be
94
+ # one built for that; the verse and bridge can take anything in the pool.
95
+ chorus_pool = [f for f in pool if FIGURES[f]["energy"][0] >= 0.4]
96
+ chorus = random.choice(chorus_pool)
97
+ rest = [f for f in pool if f != chorus]
98
+ random.shuffle(rest)
99
+ return {"verse": rest[0], "chorus": chorus, "bridge": rest[1] if len(rest) > 1 else rest[0],
100
+ "intro": "sparse_pulse", "outro": "quarter_up"}
101
+
102
+
103
+ def _ladder(chord_notes: list[int], base_octave_root: int = 48) -> list[int]:
104
+ """Chord tones from the root placed in octave 4, climbing three octaves."""
105
+ pcs = sorted({n % 12 for n in chord_notes})
106
+ root_pc = chord_notes[0] % 12
107
+ pcs = pcs[pcs.index(root_pc):] + pcs[:pcs.index(root_pc)] # start on the root
108
+ root = base_octave_root + root_pc
109
+ while root >= base_octave_root + 12:
110
+ root -= 12
111
+ ladder = []
112
+ for octave in (-1, 0, 1, 2):
113
+ for pc in pcs:
114
+ n = root + ((pc - root_pc) % 12) + 12 * octave
115
+ ladder.append(n)
116
+ return ladder
117
+
118
+
119
+ def colour_tones(chord_notes: list[int], scale_notes: list[int]) -> list[int]:
120
+ """The 7th and the 9th above the chord's root, as the scale spells them.
121
+
122
+ Returned as pitch offsets from the root (10 or 11 for the 7th, 13 or 14
123
+ for the 9th, 15 means "none"), so they work in any octave. Game-music
124
+ arps on strings and harps nearly always carry one of these — it is what
125
+ makes a harp run sound like a score rather than a music box — but they
126
+ must come from the scale: a major 7th over a mixolydian chord, or a
127
+ natural 9th in phrygian, is a wrong note.
128
+ """
129
+ pcs = {n % 12 for n in scale_notes}
130
+ root = chord_notes[0] % 12
131
+ out = []
132
+ for cands in ((10, 11), (14, 13)): # 7th, then 9th
133
+ for off in cands:
134
+ if (root + off) % 12 in pcs:
135
+ out.append(off)
136
+ break
137
+ return out
138
+
139
+
140
+ def generate_arp(segments: list[tuple[int, int, list[int]]], figure_name: str,
141
+ energy: float, melody_rows: set[int] | None = None,
142
+ scale_notes: list[int] | None = None, colour: bool = False,
143
+ rows_per_bar: int = ROWS_PER_BAR) -> list[tuple[int, int, float]]:
144
+ """Realise a figure over a chord timeline.
145
+
146
+ segments is [(start_row, end_row, chord_notes), ...]. Returns
147
+ [(row, midi_note, accent), ...]. A bar may rest at low energy (never at
148
+ high); a climbing figure plays its even bars an octave up; and where the
149
+ melody is busy in a bar (melody_rows carries its onsets) the figure's
150
+ weak steps are dropped so the arp fills gaps rather than fighting the lead.
151
+
152
+ With colour (the SNES palette), the figure's octave-root step becomes
153
+ the chord's 7th or 9th, alternating by bar, drawn from the scale via
154
+ colour_tones. Lower steps keep the triad, so the shimmer sits on top.
155
+ """
156
+ fig = FIGURES[figure_name]
157
+ out = []
158
+ total = segments[-1][1] if segments else 0
159
+ bar_index = 0
160
+ for bar_start in range(0, total, rows_per_bar):
161
+ seg = next((s for s in segments if s[0] <= bar_start < s[1]), segments[0])
162
+ chord = seg[2]
163
+ ladder = _ladder(chord)
164
+ base = len(ladder) // 4 * 1 # index of the root in octave 4
165
+ per_octave = len(ladder) // 4
166
+ if energy < 0.8 and random.random() > energy + 0.25:
167
+ bar_index += 1
168
+ continue # this bar rests
169
+ shift = per_octave if (fig["climb"] and bar_index % 2 == 1) else 0
170
+ busy = False
171
+ if melody_rows:
172
+ onsets = sum(1 for r in melody_rows if bar_start <= r < bar_start + rows_per_bar)
173
+ busy = onsets >= 5
174
+ tints = colour_tones(chord, scale_notes) if (colour and scale_notes) else []
175
+ tint = tints[bar_index % len(tints)] if tints else None
176
+ for row_off, degree, accent in fig["steps"]:
177
+ if busy and accent == W:
178
+ continue
179
+ idx = base + degree + shift
180
+ if not (0 <= idx < len(ladder)):
181
+ continue
182
+ note = ladder[idx]
183
+ # The octave-root step (and any further octave of it) carries
184
+ # the colour tone, an octave below the root it replaces so the
185
+ # 7th/9th sits just above the triad rather than far on top.
186
+ if tint is not None and degree > 0 and degree % per_octave == 0:
187
+ note = ladder[idx - per_octave] + tint
188
+ row = bar_start + row_off
189
+ if row < total:
190
+ out.append((row, note, accent))
191
+ bar_index += 1
192
+ return out
bitcomposer/bass.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ Bass generation. (Arpeggios live in arp.py.)
3
+ """
4
+
5
+ import random
6
+
7
+ from . import theory
8
+ from .pattern import ROWS_PER_BAR, ROWS_PER_BEAT
9
+
10
+
11
+ # Riffs: (row in bar, interval from the root) of a one-bar figure that
12
+ # repeats. Intervals stay chord-safe (root, fifth, octave, seventh), so a riff
13
+ # fits major and minor alike. One riff per song, drawn by the composer from
14
+ # the pack's weights; a stage theme has its own riff, not the game's.
15
+ RIFFS = {
16
+ "rock": [(0, 0), (2, 0), (4, 12), (6, 0), (8, 0), (10, 7), (12, 12), (14, 7)],
17
+ "gallop": [(0, 0), (3, 0), (4, 0), (6, 12), (8, 0), (11, 0), (12, 0), (14, 7)],
18
+ "funk": [(0, 0), (3, 12), (6, 0), (10, 7), (12, 0), (14, 10)],
19
+ "pedal_push": [(0, 0), (2, 0), (4, 0), (6, 0), (7, 12), (8, 0), (10, 0), (12, 0), (14, 12)],
20
+ "octaves": [(0, 0), (2, 12), (4, 0), (6, 12), (8, 0), (10, 12), (12, 7), (14, 12)],
21
+ "fifth_bounce": [(0, 0), (2, 7), (4, 0), (6, 7), (8, 12), (10, 7), (12, 0), (14, 7)],
22
+ "drop": [(0, 0), (2, 0), (6, 0), (8, 12), (10, 10), (12, 7), (14, 0)],
23
+ }
24
+ RIFF = RIFFS["rock"]
25
+
26
+
27
+ # The lowest bass note written, as MIDI (28 is E1, 41.2 Hz, a bass guitar's
28
+ # low string). The windows below sit on it. Before the note conversion was
29
+ # corrected the whole engine sounded an octave low and the bass reached
30
+ # 13 Hz; the floor keeps any style's octave drop or cadence walk honest.
31
+ BASS_FLOOR = 28
32
+
33
+
34
+ def floor_bass(notes: list[tuple[int, int]]) -> list[tuple[int, int]]:
35
+ """Every note below BASS_FLOOR raised by octaves until it is not."""
36
+ out = []
37
+ for row, note in notes:
38
+ while 0 < note < BASS_FLOOR:
39
+ note += 12
40
+ out.append((row, note))
41
+ return out
42
+
43
+
44
+ def generate_bass(root: int, chord_type: str,
45
+ rows: int, style: str = "steady",
46
+ weight: str = "heavy",
47
+ density: float = 1.0, riff: str | None = None,
48
+ scale_notes: list[int] | None = None) -> list[tuple[int, int]]:
49
+ """Generate a bass line. Returns [(row, midi_note), ...].
50
+
51
+ density (0..1) thins the off-beat notes. The line previously played every
52
+ slot its style produced, unconditionally, for every pattern in the song —
53
+ the same "never rests" problem the arp had. Bar downbeats are always kept,
54
+ since the bass has to state the chord.
55
+ """
56
+ # Register depends on weight, in MIDI (the conversion to the pattern is
57
+ # exact since 2026-10-17; before that everything sounded an octave low).
58
+ if weight == "light":
59
+ # MIDI 40-51 (82-165 Hz) — punchier, more melodic
60
+ bass_root = root
61
+ while bass_root >= 52:
62
+ bass_root -= 12
63
+ while bass_root < 40:
64
+ bass_root += 12
65
+ elif weight == "medium":
66
+ # MIDI 34-45 (58-110 Hz) — middle ground
67
+ bass_root = root
68
+ while bass_root >= 46:
69
+ bass_root -= 12
70
+ while bass_root < 34:
71
+ bass_root += 12
72
+ else:
73
+ # MIDI 28-39 (41-78 Hz) — deep and heavy
74
+ bass_root = root
75
+ while bass_root >= 40:
76
+ bass_root -= 12
77
+ while bass_root < 28:
78
+ bass_root += 12
79
+
80
+ chord = theory.build_chord(bass_root, chord_type)
81
+ notes = []
82
+
83
+ # Sparser step sizes for lighter weights
84
+ if weight == "light":
85
+ step_scale = 2 # half as many notes
86
+ elif weight == "medium":
87
+ step_scale = 1.5
88
+ else:
89
+ step_scale = 1
90
+
91
+ if style == "steady":
92
+ step = int(8 * step_scale)
93
+ for row in range(0, rows, step):
94
+ notes.append((row, bass_root))
95
+ elif style == "octave":
96
+ step = int(4 * step_scale)
97
+ for i, row in enumerate(range(0, rows, step)):
98
+ if i % 2 == 0:
99
+ notes.append((row, bass_root))
100
+ else:
101
+ notes.append((row, bass_root + 12))
102
+ elif style == "walking":
103
+ step = int(4 * step_scale)
104
+ for i, row in enumerate(range(0, rows, step)):
105
+ note = chord[i % len(chord)]
106
+ notes.append((row, note))
107
+ elif style == "driving":
108
+ step = int(2 * step_scale)
109
+ for row in range(0, rows, step):
110
+ notes.append((row, bass_root))
111
+ elif style == "drone":
112
+ # One note per chord, held for the whole segment; the composer slides
113
+ # between roots (porta) so the drone moves without restriking.
114
+ notes.append((0, bass_root))
115
+ elif style == "riff":
116
+ # A one-bar riff that repeats: root on the beat, the octave and the
117
+ # fifth pushing between, the way a rock bass carries a stage theme.
118
+ figure = RIFFS.get(riff or "rock", RIFF)
119
+ # A riff's seventh is only in key for minor, dorian, mixolydian and
120
+ # minor pentatonic; in a major key it sat a semitone under the
121
+ # melody's note. Snap every interval to the nearest scale tone at or
122
+ # below it, so the seventh becomes the sixth where that is the key's
123
+ # note and stays a seventh where it is.
124
+ classes = {n % 12 for n in scale_notes} if scale_notes else None
125
+ for bar in range(0, rows, ROWS_PER_BAR):
126
+ for off, iv in figure:
127
+ if bar + off >= rows:
128
+ continue
129
+ note = bass_root + iv
130
+ if classes and note % 12 not in classes:
131
+ while note % 12 not in classes and note > bass_root - 12:
132
+ note -= 1
133
+ notes.append((bar + off, note))
134
+
135
+ # A riff is a figure and repeats exactly; the thinning that lets the
136
+ # other styles rest would make it stumble from bar to bar.
137
+ if density < 1.0 and style != "riff":
138
+ notes = [
139
+ (row, note) for row, note in notes
140
+ if row % ROWS_PER_BAR == 0 or random.random() < density
141
+ ]
142
+
143
+ return notes
144
+
145
+
146
+ def pick_section_bass(bass_weight: str, section_energy: float,
147
+ bass_style: str) -> str:
148
+ """Choose bass style for a section based on weight and energy."""
149
+ if bass_style in ("drone", "riff"):
150
+ return bass_style # a drone or a riff does not lift with the section
151
+ if bass_weight == "light":
152
+ if section_energy > 0.85:
153
+ return random.choice(["steady", "walking"])
154
+ return random.choice(["steady", "steady", "walking"])
155
+ elif section_energy < 0.5:
156
+ return random.choice(["steady", "steady"])
157
+ elif section_energy > 0.85:
158
+ return random.choice(["driving", "octave", bass_style])
159
+ return bass_style
160
+
161
+
162
+ def generate_bass_timeline(segments: list[tuple[int, int, int, str]],
163
+ style: str = "steady", weight: str = "heavy",
164
+ density: float = 1.0,
165
+ riff: str | None = None,
166
+ scale_notes: list[int] | None = None) -> list[tuple[int, int]]:
167
+ """generate_bass over [(start_row, end_row, root, chord_type), ...]."""
168
+ notes = []
169
+ for start, end, root, chord_type in segments:
170
+ part = generate_bass(root, chord_type, end - start, style, weight, density, riff=riff,
171
+ scale_notes=scale_notes)
172
+ notes.extend((start + row, note) for row, note in part)
173
+ return notes