bitcomposer 1.0.0__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.
Files changed (53) hide show
  1. bitcomposer/__init__.py +6 -0
  2. bitcomposer/__main__.py +5 -0
  3. bitcomposer/arp.py +192 -0
  4. bitcomposer/bass.py +173 -0
  5. bitcomposer/builder.py +403 -0
  6. bitcomposer/cli.py +284 -0
  7. bitcomposer/composer.py +715 -0
  8. bitcomposer/drums.py +291 -0
  9. bitcomposer/echo.py +154 -0
  10. bitcomposer/harmony.py +327 -0
  11. bitcomposer/instruments.py +216 -0
  12. bitcomposer/it_format.py +376 -0
  13. bitcomposer/melody.py +543 -0
  14. bitcomposer/mix.py +66 -0
  15. bitcomposer/names.py +158 -0
  16. bitcomposer/packs/__init__.py +30 -0
  17. bitcomposer/packs/evermore.py +358 -0
  18. bitcomposer/packs/ffvi.py +475 -0
  19. bitcomposer/packs/mana.py +373 -0
  20. bitcomposer/packs/megamanx.py +397 -0
  21. bitcomposer/pattern.py +474 -0
  22. bitcomposer/player/__init__.py +4 -0
  23. bitcomposer/player/__main__.py +4 -0
  24. bitcomposer/player/app.py +327 -0
  25. bitcomposer/player/audio.py +162 -0
  26. bitcomposer/player/autostart.py +93 -0
  27. bitcomposer/player/backend.py +479 -0
  28. bitcomposer/player/desktop.py +139 -0
  29. bitcomposer/player/engine.py +601 -0
  30. bitcomposer/player/fonts/JetBrainsMono-Bold.ttf +0 -0
  31. bitcomposer/player/fonts/JetBrainsMono-Regular.ttf +0 -0
  32. bitcomposer/player/fonts/LICENSE.txt +93 -0
  33. bitcomposer/player/icons/bitcomposer.svg +34 -0
  34. bitcomposer/player/mediakeys_win.py +47 -0
  35. bitcomposer/player/mpris.py +317 -0
  36. bitcomposer/player/openmpt.py +208 -0
  37. bitcomposer/player/qml/Main.qml +1764 -0
  38. bitcomposer/player/update.py +203 -0
  39. bitcomposer/player/window.py +113 -0
  40. bitcomposer/registers.py +78 -0
  41. bitcomposer/samples/__init__.py +385 -0
  42. bitcomposer/samples/chip.py +459 -0
  43. bitcomposer/samples/dsp.py +459 -0
  44. bitcomposer/samples/genesis.py +369 -0
  45. bitcomposer/samples/snes.py +242 -0
  46. bitcomposer/structure.py +382 -0
  47. bitcomposer/theory.py +248 -0
  48. bitcomposer-1.0.0.dist-info/METADATA +267 -0
  49. bitcomposer-1.0.0.dist-info/RECORD +53 -0
  50. bitcomposer-1.0.0.dist-info/WHEEL +5 -0
  51. bitcomposer-1.0.0.dist-info/entry_points.txt +3 -0
  52. bitcomposer-1.0.0.dist-info/licenses/LICENSE +21 -0
  53. bitcomposer-1.0.0.dist-info/top_level.txt +1 -0
bitcomposer/builder.py ADDED
@@ -0,0 +1,403 @@
1
+ """One pattern at a time: the chord timeline, the key plan, and every layer
2
+ (melody, harmony, bass, arp, drums, echo returns) written into it."""
3
+ import random
4
+ import zlib
5
+
6
+ from . import theory
7
+ from .it_format import ITPattern, ITNote
8
+ from .pattern import CH_MELODY, CH_HARMONY, CH_HARMONY2, CH_HARMONY3, CH_BASS, CH_ARP, CH_KICK, CH_SNARE, CH_HIHAT, CH_TOM, CH_CRASH, CH_OPEN_HAT, CH_ECHO_ALL, NUM_CHANNELS, ROWS_PER_PATTERN, ROWS_PER_BAR, humanize_volume, apply_notes_to_pattern, apply_vibrato, apply_portamento, apply_harmony_fade, apply_fade, apply_echo_taps, apply_sustain_safety_net, end_note, apply_strum, STRUM_ROW, STRUM_TICKS
9
+ from .drums import render_drums
10
+ from .melody import generate_motif, generate_melody, generate_counter_melody, add_melody_cuts, MELODY_LOW, MELODY_HIGH
11
+ from .harmony import generate_harmony_timeline, anchors_for, window_for, stab_rows, harmony_level, HARMONY_VOICINGS
12
+ from .bass import generate_bass_timeline, floor_bass
13
+ from .arp import generate_arp
14
+
15
+
16
+ # The channels each part writes, for the intro entries.
17
+ PART_CHANNELS = {
18
+ "melody": (CH_MELODY,), "harmony": (CH_HARMONY, CH_HARMONY2, CH_HARMONY3),
19
+ "bass": (CH_BASS,), "arp": (CH_ARP,),
20
+ "drums": (CH_KICK, CH_SNARE, CH_HIHAT, CH_TOM, CH_CRASH, CH_OPEN_HAT),
21
+ }
22
+
23
+ # How harmony notes decay after their note-on, per voicing: (slide, rows to
24
+ # hold first), or None for no slide. The chug, pulse and rhythmic hits are
25
+ # short, so a per-tick slide from the start is right. sustain is a pad: it
26
+ # holds for most of its 32-row interval and then takes a fine (per-row)
27
+ # slide down into the next hit (its hold is set per pattern so the slide,
28
+ # one per row, reaches zero at the cut whatever the written volume: at the
29
+ # old fixed 18 rows a quieter chord went silent early and re-attacked out
30
+ # of nothing). A stab takes no slide: it holds at level
31
+ # for the rows harmony.stab_rows gives the voice and ends on a note-off
32
+ # into the family's release. It used to take D02, which at volume 22 had
33
+ # the note silent eleven ticks after its attack whatever the cut row said.
34
+ HARMONY_FADE = {"stabs": None, "rhythmic": (0x03, 0), "sustain": (0xF1, None),
35
+ "ostinato": (0x03, 0), "pulse": (0x04, 0),
36
+ "chug_sync": (0x03, 0), "chug_offbeat": (0x03, 0), "chug_gallop": (0x04, 0)}
37
+
38
+ # The harmony's written volume per voice at full energy, before the family
39
+ # level and the voice-count scale (harmony.harmony_level). The distance
40
+ # under the lead is set by the harmony channels' volume in mix.py, so the
41
+ # note volumes keep their resolution and the humanisation stays small
42
+ # against them. The counter-line has its own level: a second melodic line,
43
+ # it sits a little under the lead, not at the chord's level.
44
+ HARMONY_VOL = 22
45
+ COUNTER_VOL = 32
46
+
47
+ # Chance that a pattern changes chord at its halfway point, by section type.
48
+ HALF_PATTERN_CHANGE = {
49
+ "intro": 0.2, "verse": 0.5, "chorus": 0.55, "bridge": 0.6, "outro": 0.3,
50
+ }
51
+
52
+
53
+ def _content_seed(song_salt: int, group: str, chord_idx: int) -> int:
54
+ """Stable per-(reuse group, chord) seed so repeats generate the same music.
55
+
56
+ crc32 rather than hash() because str hashing is salted per process, which
57
+ would make -s deterministic within a run but not across runs.
58
+ """
59
+ return song_salt ^ zlib.crc32(f"{group}:{chord_idx}".encode())
60
+
61
+
62
+ def _energy_tier(energy: float) -> int:
63
+ """Bucket section energy to 0.2-wide tiers.
64
+
65
+ Sections a hair apart on the energy curve (chorus at 0.85 and 0.90) render
66
+ close enough to share a pattern; a final chorus at 1.0 lands in its own
67
+ tier and gets its own louder copy of the same music.
68
+ """
69
+ return round(energy * 5)
70
+
71
+
72
+ def _pick_section_motif(sec_type: str, use_phrased: bool,
73
+ chorus_motif, verse_motif, bridge_motif,
74
+ rng: random.Random | None = None, scale_size: int = 7) -> dict | None:
75
+ """Pick the appropriate motif for a section type."""
76
+ if not use_phrased:
77
+ return None
78
+ if sec_type == "chorus":
79
+ return chorus_motif
80
+ elif sec_type == "verse":
81
+ return verse_motif
82
+ elif sec_type == "bridge":
83
+ return bridge_motif
84
+ elif sec_type == "intro":
85
+ return chorus_motif or verse_motif
86
+ else:
87
+ # Outro/tag: a fresh motif on the melody's own stream, so its draws
88
+ # never touch the arrangement.
89
+ r = rng or random
90
+ return generate_motif(length=r.choice([3, 4]), rng=rng, scale_size=scale_size)
91
+
92
+
93
+ # Related keys a section can move to, as (semitone shift, scale family).
94
+ # "relative" is the relative major of a minor key or the relative minor of a
95
+ # major one — the move game composers reach for at a chorus or a bridge; the
96
+ # old +1 semitone chorus was the truck-driver key change and is gone.
97
+ RELATIVE = {
98
+ "natural_minor": (3, "major"), "harmonic_minor": (3, "major"), "dorian": (3, "major"),
99
+ "phrygian": (3, "major"), "minor_pentatonic": (3, "major_pentatonic"),
100
+ "major": (-3, "natural_minor"), "mixolydian": (-3, "natural_minor"),
101
+ "major_pentatonic": (-3, "minor_pentatonic"),
102
+ }
103
+
104
+
105
+ def plan_keys(scale_name: str, keys: str = "moving") -> dict[str, tuple[int, str]]:
106
+ """Per section type: (semitone shift from the home key, scale name).
107
+
108
+ Verses and themes stay home. A chorus stays home half the time, goes to
109
+ the relative key a third of the time, or lifts to the subdominant. A
110
+ bridge most often moves — that is what a bridge is for — to the relative
111
+ key or the subdominant.
112
+
113
+ keys="home" pins every section to the home key. The moving plan trades
114
+ consistency for the chance of a striking song, which is right for a
115
+ generator you browse; a loop that has to sit under a game scene for ten
116
+ minutes, a track that must match a fixed-key stinger, or an A/B test of
117
+ anything else in the engine all want the stable version.
118
+ """
119
+ rel_shift, rel_scale = RELATIVE.get(scale_name, (0, scale_name))
120
+ home = (0, scale_name)
121
+ # Draw either way, so a seed makes the same song with the keys pinned
122
+ # apart from the key moves themselves.
123
+ chorus = random.choices([home, (rel_shift, rel_scale), (5, scale_name)], weights=[50, 32, 18], k=1)[0]
124
+ bridge = random.choices([home, (rel_shift, rel_scale), (5, scale_name)], weights=[40, 35, 25], k=1)[0]
125
+ if keys == "home":
126
+ chorus = bridge = home
127
+ return {"verse": home, "chorus": chorus, "bridge": bridge, "intro": home, "outro": home}
128
+
129
+
130
+ def _chord_segments(key: int, scale_name: str, scale_notes: list[int],
131
+ entries: list, next_entry, sec_type: str,
132
+ cadence_root: int | None = None) -> list[tuple]:
133
+ """Chord timeline for one pattern: [(start_row, end_row, root, type, notes)].
134
+
135
+ `entries` holds one chord (normal harmonic rhythm) or two (compact, two
136
+ bars each). With one chord, HALF_PATTERN_CHANGE odds move the back two
137
+ bars to a related chord (theory.secondary_chord). With cadence_root set
138
+ — the root the next section opens on — the back half becomes that
139
+ root's dominant instead, so the section ends with a V that resolves into
140
+ the next one.
141
+ """
142
+ def seg(start, end, e):
143
+ _, chord_type, _ = theory.unpack_chord(e)
144
+ root = theory.chord_root_for(key, scale_notes, e)
145
+ return (start, end, root, chord_type, theory.build_chord(root, chord_type))
146
+
147
+ half = ROWS_PER_PATTERN // 2
148
+ if cadence_root is not None:
149
+ dom = cadence_root + 7
150
+ while dom > key + 12:
151
+ dom -= 12
152
+ ctype = "power" if "pentatonic" in scale_name else "major"
153
+ return [seg(0, half, entries[0]), (half, ROWS_PER_PATTERN, dom, ctype, theory.build_chord(dom, ctype))]
154
+ if len(entries) >= 2:
155
+ return [seg(0, half, entries[0]), seg(half, ROWS_PER_PATTERN, entries[1])]
156
+ entry = entries[0]
157
+ if random.random() < HALF_PATTERN_CHANGE.get(sec_type, 0.4):
158
+ second = theory.secondary_chord(scale_name, entry, next_entry)
159
+ return [seg(0, half, entry), seg(half, ROWS_PER_PATTERN, second)]
160
+ return [seg(0, ROWS_PER_PATTERN, entry)]
161
+
162
+
163
+ def _compose_pattern(*, scale_notes, segments, layers, sec_type, section_energy,
164
+ section_density, section_bass, sample_map,
165
+ melody_sample, use_phrased, section_motif, melody_rng,
166
+ chord_idx, is_answer,
167
+ voicing_style, num_harmony_voices,
168
+ vibrato_speed, vibrato_depth,
169
+ bass_weight, use_bass_porta, porta_speed,
170
+ arp_figure, arp_colour, groove, drum_fill, breakdown,
171
+ n_patterns, is_ending, ending_style,
172
+ swing_amount, speed, bpm, is_intro, echo_sends,
173
+ pickup_root=None, intro=None, counter_chance=0.3, hat_stroke=1.0,
174
+ crash_chance=1.0, fill_crash=True, kit_rng=None,
175
+ power_chords=False, bass_riff=None, harmony_family=None,
176
+ harmony_preset=None, harmony_onset_ms=0.0,
177
+ registers: dict | None = None) -> ITPattern:
178
+ """Compose a single pattern with all layers."""
179
+ pat = ITPattern(rows=ROWS_PER_PATTERN, channels=NUM_CHANNELS)
180
+ melody = []
181
+ # Where the parts sit: the song's register plan (registers.py). Without
182
+ # one (a direct caller), the old window and the family's own register.
183
+ registers = registers or {}
184
+ lead_window = registers.get("lead_window", (MELODY_LOW, MELODY_HIGH))
185
+ lead_floor = registers.get("lead_floor")
186
+ harmony_window = registers.get("harmony_window", window_for(harmony_family))
187
+
188
+ # The timeline in the shapes each generator wants.
189
+ chord_notes = segments[0][4] # the pattern's main chord
190
+ melody_timeline = [(s, notes) for s, _, _, _, notes in segments]
191
+ harmony_segments = [(s, e, notes) for s, e, _, _, notes in segments]
192
+ if power_chords:
193
+ # Root, fifth, octave: the guitarist's chord, no third.
194
+ harmony_segments = [(s, e, [root, root + 7, root + 12]) for s, e, root, _, _ in segments]
195
+ bass_segments = [(s, e, root, ctype) for s, e, root, ctype, _ in segments]
196
+
197
+ # Scale volumes by section energy. The harmony's level is a property of
198
+ # the voice family and the voice count (harmony.harmony_level).
199
+ melody_vol = int(42 * section_energy)
200
+ arp_vol = int(36 * section_energy)
201
+ harmony_fade = HARMONY_FADE.get(voicing_style, (0x02, 0))
202
+ row_ms = 60000.0 / (bpm * 4) # four rows to a beat
203
+ # A stab's hold comes from the voice: its family's length or three times
204
+ # its attack, in rows at this tempo; every other mode keeps its own.
205
+ harmony_cut = None
206
+ if voicing_style == "stabs":
207
+ harmony_cut = stab_rows(harmony_family, harmony_onset_ms, row_ms,
208
+ HARMONY_VOICINGS["stabs"]["interval"])
209
+ harmony_hold_ms = (harmony_cut if harmony_cut is not None
210
+ else HARMONY_VOICINGS.get(voicing_style, HARMONY_VOICINGS["stabs"])["cut_after"]) * row_ms
211
+
212
+ # ── Melody ──
213
+ if layers["melody"]:
214
+ # The melody has its own random stream, seeded from the pattern's
215
+ # content seed, so the arrangement around it does not move when the
216
+ # melody code changes. Register follows section energy: verses low,
217
+ # choruses high.
218
+ melody = generate_melody(
219
+ scale_notes, chord_notes, ROWS_PER_PATTERN, section_density,
220
+ motif=section_motif, is_answer=is_answer, phrased=use_phrased,
221
+ chord_timeline=melody_timeline, rng=melody_rng,
222
+ register=0.2 + 0.7 * max(0.0, min(1.0, (section_energy - 0.5) / 0.5)),
223
+ window=lead_window, floor=lead_floor,
224
+ )
225
+ apply_notes_to_pattern(
226
+ pat, CH_MELODY, melody, melody_sample,
227
+ volume=melody_vol, humanize=True, rng=melody_rng,
228
+ )
229
+ apply_vibrato(pat, CH_MELODY, melody,
230
+ speed=vibrato_speed, depth=vibrato_depth)
231
+
232
+ # ── Harmony ──
233
+ if layers["harmony"]:
234
+ harmony_channels = [CH_HARMONY, CH_HARMONY2, CH_HARMONY3]
235
+
236
+ # The counter-line, when a chorus or bridge pattern draws one, has
237
+ # the third channel to itself; the chord keeps the first two. It
238
+ # used to take the first channel at the chord's level and with the
239
+ # chord's fade, so under a stab mode every counter note died like a
240
+ # stab, and the chord voice moved channel (and echo pan) between
241
+ # the patterns that had a counter and the ones that did not.
242
+ has_counter = bool(melody) and sec_type in ("chorus", "bridge") \
243
+ and random.random() < counter_chance
244
+ n_voices = min(2, num_harmony_voices) if has_counter else num_harmony_voices
245
+
246
+ # Register per family from the plan (the lead is placed above the
247
+ # chord, so the chord is not capped under the lead any more); the
248
+ # level per family and voice count.
249
+ voices = generate_harmony_timeline(
250
+ harmony_segments, voicing=voicing_style, num_voices=n_voices,
251
+ anchors=anchors_for(harmony_family, n_voices),
252
+ window=harmony_window, cut_after=harmony_cut,
253
+ )
254
+ harmony_vol = int(HARMONY_VOL * section_energy
255
+ * harmony_level(harmony_family, harmony_preset, n_voices, harmony_hold_ms))
256
+ for v_idx, voice_notes in enumerate(voices):
257
+ if v_idx >= len(harmony_channels):
258
+ break
259
+ ch = harmony_channels[v_idx]
260
+ apply_notes_to_pattern(
261
+ pat, ch, voice_notes,
262
+ sample_map["harmony"],
263
+ volume=humanize_volume(harmony_vol, 4),
264
+ humanize=True,
265
+ )
266
+ if voicing_style == "rhythmic" and v_idx > 0:
267
+ apply_strum(pat, ch, voice_notes, STRUM_ROW, STRUM_TICKS)
268
+ if harmony_fade is not None:
269
+ slide, hold = harmony_fade
270
+ if hold is None:
271
+ # Hold, then a one-per-row slide that lands on zero at the cut.
272
+ hold = max(0, HARMONY_VOICINGS[voicing_style]["cut_after"] - 1 - harmony_vol)
273
+ apply_harmony_fade(pat, ch, voice_notes, fade_rate=slide, hold_rows=hold)
274
+
275
+ if has_counter:
276
+ counter = generate_counter_melody(
277
+ scale_notes, chord_notes, melody, ROWS_PER_PATTERN, rng=melody_rng,
278
+ window=lead_window)
279
+ counter = add_melody_cuts(counter, ROWS_PER_PATTERN, max_sustain=8)
280
+ apply_notes_to_pattern(
281
+ pat, CH_HARMONY3, counter, sample_map.get("counter", sample_map["harmony"]),
282
+ volume=int(COUNTER_VOL * section_energy), humanize=True, rng=melody_rng,
283
+ )
284
+
285
+ # ── Bass ──
286
+ if layers["bass"]:
287
+ # Keep the bass present but not relentless: even at full energy a few
288
+ # off-beats drop out, and quiet sections thin further.
289
+ bass = generate_bass_timeline(bass_segments, section_bass,
290
+ weight=bass_weight,
291
+ density=0.6 + 0.35 * section_energy, riff=bass_riff,
292
+ scale_notes=scale_notes)
293
+ bass = floor_bass(bass)
294
+ # Volume-column values are 0-64. The old 150/100 for medium/light were
295
+ # not volumes at all: in the IT volume column 150 is a pan command and
296
+ # 100 a volume slide down, which left light basses nearly silent.
297
+ bass_vol = {"heavy": 255, "medium": 52, "light": 44}.get(bass_weight, 255)
298
+ if use_bass_porta and len(bass) > 1:
299
+ apply_portamento(
300
+ pat, CH_BASS, bass, sample_map["bass"],
301
+ volume=bass_vol, porta_speed=porta_speed, humanize=False,
302
+ )
303
+ else:
304
+ # A pack may carry a second bass sample for the top of the line
305
+ # (an octave pop on a heavy bass thins out on the low sample).
306
+ # Every note picks its sample by pitch; the channel is the same.
307
+ ceiling = {"heavy": 40, "medium": 46, "light": 52}.get(bass_weight, 40)
308
+ hi = sample_map.get("bass_hi")
309
+ groups = [(sample_map["bass"], [n for n in bass if hi is None or n[1] < ceiling])]
310
+ if hi is not None:
311
+ groups.append((hi, [n for n in bass if n[1] >= ceiling]))
312
+ for inst, notes_ in groups:
313
+ apply_notes_to_pattern(
314
+ pat, CH_BASS, notes_, inst,
315
+ volume=bass_vol if bass_vol < 255 else 255,
316
+ humanize=True,
317
+ )
318
+ if pickup_root is not None and bass:
319
+ # Walk up into the next section's root over the last two beats:
320
+ # the fifth below, a step, then the leading tone — the pickup a
321
+ # bass player plays into a chorus.
322
+ target = pickup_root
323
+ lo = min(n for _, n in bass)
324
+ while target - 12 >= lo:
325
+ target -= 12
326
+ while target < 36:
327
+ target += 12
328
+ walk = floor_bass([(ROWS_PER_PATTERN - 8, target - 5), (ROWS_PER_PATTERN - 4, target - 2),
329
+ (ROWS_PER_PATTERN - 2, target - 1)])
330
+ for row in range(ROWS_PER_PATTERN - 8, ROWS_PER_PATTERN):
331
+ pat.data[row][CH_BASS] = ITNote()
332
+ apply_notes_to_pattern(pat, CH_BASS, walk, sample_map["bass"],
333
+ volume=bass_vol if bass_vol < 255 else 255)
334
+ bass = [b for b in bass if b[0] < ROWS_PER_PATTERN - 8] + walk
335
+ if bass_weight in ("light", "medium"):
336
+ cut_after = 2 if bass_weight == "light" else 4
337
+ for row, _ in bass:
338
+ cut_row = row + cut_after
339
+ if cut_row < ROWS_PER_PATTERN:
340
+ if pat.data[cut_row][CH_BASS].note == 0:
341
+ pat.data[cut_row][CH_BASS] = ITNote(note=end_note(sample_map["bass"]))
342
+
343
+ # ── Arpeggio ──
344
+ if layers["arp"]:
345
+ # A figure with accents and rests, chosen per section role, that
346
+ # thins where the lead is busy; see arp.py.
347
+ arp = generate_arp(harmony_segments, arp_figure, section_energy,
348
+ melody_rows={r for r, n in melody if n != -1},
349
+ scale_notes=scale_notes, colour=arp_colour)
350
+ apply_notes_to_pattern(
351
+ pat, CH_ARP, [(r, n) for r, n, _ in arp], sample_map["arp"],
352
+ volume=arp_vol, humanize=False,
353
+ )
354
+ # Accents are the figure's own shape; no jitter on top, or every bar
355
+ # is unique again and the ear reads it as a sequencer.
356
+ for r, _, accent in arp:
357
+ pat.data[r][CH_ARP].volume = max(1, min(64, int(round(arp_vol * accent))))
358
+
359
+ # ── Drums ──
360
+ if layers["drums"]:
361
+ # A four-bar phrase from the section's groove, with its fill (if any)
362
+ # in the last bar, a crash on the first beat of every other pattern
363
+ # and on section starts; see drums.py.
364
+ render_drums(pat, groove, sample_map=sample_map, energy=section_energy,
365
+ bpm=bpm, swing=swing_amount, speed=speed, fill=drum_fill,
366
+ hat_stroke=hat_stroke, crash_chance=crash_chance,
367
+ fill_crash=fill_crash, kit_rng=kit_rng,
368
+ breakdown=breakdown,
369
+ crash_on_one=(chord_idx == 0 and sec_type in ("chorus", "bridge"))
370
+ or (chord_idx % 2 == 0 and section_energy >= 0.8))
371
+
372
+ # ── Intro entries ──
373
+ # In the first intro pattern each part waits for its bar (structure.INTROS);
374
+ # before the echo returns, so nothing is sent from a bar a part is not in.
375
+ if intro and chord_idx == 0:
376
+ for part, bar in intro["enter"].items():
377
+ for ch in PART_CHANNELS[part]:
378
+ for row in range(min(bar * ROWS_PER_BAR, ROWS_PER_PATTERN)):
379
+ pat.data[row][ch] = ITNote()
380
+
381
+ # ── Echo returns ──
382
+ # After every sent part is written: the lead per note, harmony and arp
383
+ # whole. The safety net below bounds the returns like any tonal channel.
384
+ if echo_sends:
385
+ for src, taps, hold, rest, accent in echo_sends:
386
+ if taps:
387
+ apply_echo_taps(pat, src, taps, hold, rest, accent)
388
+
389
+ # ── Sustain safety net ──
390
+ apply_sustain_safety_net(pat)
391
+
392
+ # ── Section transitions ──
393
+ if is_intro and chord_idx == 0 and (intro is None or intro["fade_in"]):
394
+ for ch in [CH_MELODY, CH_HARMONY, CH_HARMONY2, CH_HARMONY3, CH_BASS, CH_ARP,
395
+ *CH_ECHO_ALL]:
396
+ apply_fade(pat, ch, fade_in=True, rows_count=16)
397
+
398
+ if is_ending and chord_idx == n_patterns - 1:
399
+ if ending_style in ("fadeout", "tag"):
400
+ for ch in range(NUM_CHANNELS):
401
+ apply_fade(pat, ch, fade_out=True, rows_count=24)
402
+
403
+ return pat