From 4ab1202bec14b4668cd2d8694661bae4f951af63 Mon Sep 17 00:00:00 2001 From: hampuslinden <62944155+hampuslinden@users.noreply.github.com> Date: Sun, 5 Jul 2026 12:25:42 +0100 Subject: [PATCH] feat: adds final screen --- .gitattributes | 2 + Makefile | 20 ++- music/summergames.mid | 3 + res/soundbank.asm | 2 +- res/soundbank.bnk | Bin 21916 -> 24126 bytes res/soundbank.h | 2 + res/summergames.it | Bin 0 -> 1753 bytes src/main.c | 257 ++++++++++++++++++++++++++++++--- tools/make_music_it.py | 315 +++++++++++++++++++++++++++++++++++++++++ 9 files changed, 575 insertions(+), 26 deletions(-) create mode 100644 music/summergames.mid create mode 100644 res/summergames.it create mode 100644 tools/make_music_it.py diff --git a/.gitattributes b/.gitattributes index 328c450..cbd38cf 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,5 @@ *.bmp filter=lfs diff=lfs merge=lfs -text *.png filter=lfs diff=lfs merge=lfs -text *.wav filter=lfs diff=lfs merge=lfs -text +*.mid filter=lfs diff=lfs merge=lfs -text +*.midi filter=lfs diff=lfs merge=lfs -text diff --git a/Makefile b/Makefile index dff40c4..2881a73 100644 --- a/Makefile +++ b/Makefile @@ -19,8 +19,12 @@ endif # BEFORE including snes_rules: # list every .it file (effects first, then any music) in AUDIOFILES so smconv -# builds the soundbank, and point SOUNDBANK at where to emit it. -AUDIOFILES := res/sfx.it +# builds the soundbank, and point SOUNDBANK at where to emit it. sfx.it MUST +# stay first -- smconv treats the first IT file as the shared effects bank and +# bakes it into every module after it, so effects stay playable once a music +# module is loaded (see res/summergames.it's recipe below and the +# PVSnesLib audio/effectsandmusic example). +AUDIOFILES := res/sfx.it res/summergames.it export SOUNDBANK := res/soundbank include $(PVSNESLIB_HOME)/devkitsnes/snes_rules @@ -28,8 +32,9 @@ include $(PVSNESLIB_HOME)/devkitsnes/snes_rules # ROMNAME is used by snes_rules to name the linked ROM. export ROMNAME := snesgame2 -# smconv flags: -s strip, -o output, -V verbose, -b 5 = place the bank in ROM bank 5 -SMCONVFLAGS := -s -o $(SOUNDBANK) -V -b 5 +# smconv flags: -s strip, -o output, -V verbose, -b 5 = place the bank in ROM +# bank 5, -f = check combined size against the 1st (effects) IT file. +SMCONVFLAGS := -s -o $(SOUNDBANK) -V -b 5 -f .PHONY: all artifacts bitmaps musics brrsound clean @@ -99,3 +104,10 @@ res/gunshot.brr: res/gunshot.wav $(BRCONV) -e $< $@ brrsound: res/gunshot.brr + +# Game-completion music: converted from the MIDI by tools/make_music_it.py +# (smconv only accepts Impulse Tracker modules, not raw MIDI). Regenerate with +# `python3 tools/make_music_it.py` when tweaking the MIDI or the converter. +res/summergames.it: music/summergames.mid tools/make_music_it.py + @echo convert midi -> it ... $(notdir $@) + python3 tools/make_music_it.py diff --git a/music/summergames.mid b/music/summergames.mid new file mode 100644 index 0000000..aa3ee76 --- /dev/null +++ b/music/summergames.mid @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:89bc7bc68e00f2a8ee91727f9b736a1749d93728b27bf72b8139e5487bc4a57f +size 2832 diff --git a/res/soundbank.asm b/res/soundbank.asm index 0de1030..ecf429d 100644 --- a/res/soundbank.asm +++ b/res/soundbank.asm @@ -1,6 +1,6 @@ ;************************************************ ; snesmod soundbank data * -; total size: 21916 bytes * +; total size: 24126 bytes * ;************************************************ .include "hdr.asm" diff --git a/res/soundbank.bnk b/res/soundbank.bnk index 3e7f71687f6ce9db70e4726e74bdf9f9569fe5f4..9d0413c988e12ce2624c45731334755f8098a0fc 100644 GIT binary patch delta 1737 zcmcIlJxc>I7*4LOr%(`!qaU?Xhl+ANY>TxXh`WPhr-I_{aAS0ERM1XBhdL=bh;$JY zK^H-AP+Wwz_yfcrAV^K}CMI{iD;6{*ZJ+1f&*x3r+8MsN#zQ7D(BU?!>|Bg&;{D{{ z=<_c6JV&)#H1UE?UQwcr((T3peu!l3sDWZ=0BzMHj#x>t5M6}5@r8{@^$A~A-*7y- z@o3#sP>ha^PbLK$HOdwGwPd7H?1L#`C zi_mn+PrxttWemSmfC=cXlzyC@aJ)tbB&yIKl(pO%_?&8H<;*Nt6sqth-4)KKFB6kgls}$AuS2SMiL{jG(q?Z6ADWTODSym z06qX?%@5G)x${~U2MZxF-eh;@+gF;yg=r=%h(B&SHjxmO{>w=Vp*^onDMs(Pp4Im zzONc!{Irc1g7|J1=zh!hrug=GAb20W9QS)(f%|0anuJY&$AtQ%Zj z!iv+>b-%Cpb$<-=9}u+Zs^B(AFo|{Y&@I-9Ap@w<0LekSjb>?nrHwrKk9HN*QRwY# z-Y$#f@&a9%+jU5{3)c3%QzZu(;dPNPw>OR8h9@7!hR4a%|g2yyUbbx7?>WcNj zsd0WupH;&j>Q(y4;K*J$+w)2bKQBkdLBc&3_S%vDDPvVwLAZ#ywDr{>D#;tAXGxR= z2QqSfaB5sq_UbjQ8dj*c_HJ0O(nt0-RSZ@{vtBLxnN*U^XVb}eG#m^^Y3XUSB? HFIVedSNy?O literal 0 HcmV?d00001 diff --git a/src/main.c b/src/main.c index a4daf9f..6c267f7 100644 --- a/src/main.c +++ b/src/main.c @@ -8,6 +8,7 @@ ---------------------------------------------------------------------------------*/ #include +#include // strlen/memset, used to blank the credits crawl text #include "sprites.inc" // player: sprites_til/_tilend, sprites_pal/_palend #include "enemy.inc" // enemy: enemy_til/_tilend, enemy_pal/_palend @@ -90,6 +91,8 @@ const unsigned char jingleNotes[] = { #define PLAYFIELD_TOP 24 // top 3 tile-rows (y 0..23) are the stats bar #define SCORE_PER_KILL 100 #define TANK_SCORE 300 // a 3-hit TAC-2 joystick is worth triple a console +#define WIN_SCORE 5000 // score that triggers the "game completed" screen; + // tune this down (e.g. 200) to reach it quickly for debugging #define TANK_HEALTH 3 // hits to destroy the tough enemy #define TANK_CHANCE 5 // ~1 in this many spawns is a tough enemy #define HURT_FRAMES 8 // brief flicker after a non-lethal hit @@ -104,6 +107,24 @@ const unsigned char jingleNotes[] = { // so the catch box is the upper-left 16x16 of the cell. #define POWERUP_SPEED 1 // pixels/frame the dropped bomb falls #define PLAYER_FLASH_FRAMES 60 // ~1s of blink + invulnerability after a hit +// SNES BG scroll registers only hold whole pixels, so any slow crawl is really a +// "move 1px every few frames" stair-step -- a fixed "wait N frames" step makes +// that stair-step as even as it can be, but a sub-pixel accumulator (mirroring +// enemySpeed/enemyFrac's descent) allows non-integer average speeds too, so the +// step spacing can alternate (e.g. 5,5,6,5,5,6 frames) instead of being locked +// to a single whole-number gap -- marginally smoother for the same overall pace. +#define CREDITS_SCROLL_SPEED 51 // 1/256ths of a px per frame (~1px every 5 frames) +#define CREDITS_START_DELAY_FRAMES 90 // blank pause after the screen clears, before text appears +#define WIN_PAUSE_FRAMES 30 // brief freeze right after the winning kill +#define WIN_CONGRATS_FRAMES 300 // ~5s (60fps) the "CONGRATULATIONS" banner stays up +#define WIN_PHASE_PAUSE 0 // frozen field, about to show the banner +#define WIN_PHASE_CONGRATS 1 // "CONGRATULATIONS" banner is up +#define WIN_PHASE_CREDITS 2 // screen cleared, credits crawling +// MOD_SUMMERGAMES has no baked-in loop point (see tools/make_music_it.py -- +// its .it pattern data has no jump/break effect), so the game manually +// restarts it once its known length has played out: 256 rows at +// speed=3/tempo=160 (2.5*3/160 = 0.046875s/row) = 12s = 720 frames at 60fps. +#define MUSIC_LOOP_FRAMES 720 // Each gfx4snes 32x32 sprite is a 64-tile (0x400-word) block; lay them end to end. #define VRAM_PLAYER 0x0000 @@ -173,6 +194,19 @@ unsigned char playerFlash = 0; // >0 = recently hit: blinking + invuln unsigned char gameOver = 0; char livesStr[4]; +// --- Game-complete state --- +// Reusing BG0 (the text/HUD layer) for the credits crawl: it's otherwise idle +// once play is frozen, and hardware-scrolling it costs nothing per frame, just +// like the BG2 starfield's scroll trick. +unsigned char gameWon = 0; // true once WIN_SCORE is reached +unsigned char winPhase = WIN_PHASE_PAUSE; // where we are in the win sequence +unsigned short winTimer = 0; // counts down frames within the current phase +unsigned short creditsScroll = 0; // BG0 vertical scroll offset for the crawl +unsigned char creditsFrac = 0; // sub-pixel accumulator for CREDITS_SCROLL_SPEED +unsigned char creditsPause = 0; // counts down CREDITS_START_DELAY_FRAMES before + // the crawl text is drawn and starts moving +unsigned short musicFrame = 0; // frames since MOD_SUMMERGAMES last (re)started + // --- Bomb inventory --- unsigned char bombs = START_BOMBS; unsigned char lastBombs = 0xFF; // force first HUD draw @@ -370,6 +404,89 @@ void drawBombs(void) consoleDrawText(27, 1, bombStr); } +// --- "Game completed" credits crawl ------------------------------------------- +// BG0's tilemap is a single wrapping 32x32-tile (256x256px) screen, the same +// layout the BG2 starfield scrolls over. The block is drawn starting at row 1 +// (the topmost non-clipped row) so the first line sits right at the top edge +// the moment it appears, then continuously nudging bgSetScroll down (like the +// starfield's "falling" trick) drifts the whole block down the screen and +// loops it through the blank rows below -- a classic scrolling-credits look +// with no per-frame text redraw needed. +#define NUM_CREDIT_LINES 7 +static const unsigned char creditRow[NUM_CREDIT_LINES] = { 1, 3, 6, 8, 10, 12, 15 }; +static const unsigned char creditCol[NUM_CREDIT_LINES] = { 4, 4, 11, 9, 5, 3, 7 }; +static const char *const creditText[NUM_CREDIT_LINES] = { + "Thank you for playing.", + "You completed the game.", + "Thanks to:", + "Hampus (coder)", + "Claude (actuall coder)", + "Terry (design & testing)", + "Please play again" +}; + +void drawCredits(void) +{ + unsigned char k; + for (k = 0; k < NUM_CREDIT_LINES; k++) + consoleDrawText(creditCol[k], creditRow[k], (char *)creditText[k]); +} + +// Blank each credit line back out (same spot, spaces instead of text) so +// nothing is left behind once the crawl ends and gameplay resumes. +void eraseCredits(void) +{ + unsigned char k, len; + char blank[26]; + for (k = 0; k < NUM_CREDIT_LINES; k++) + { + len = strlen(creditText[k]); + memset(blank, ' ', len); + blank[len] = '\0'; + consoleDrawText(creditCol[k], creditRow[k], blank); + } +} + +// Hide every gameplay sprite and the starfield, leaving just the flat sky +// colour. Used both when the credits crawl begins and when the player skips +// the whole win sequence early with START. +void clearPlayfield(void) +{ + unsigned char i, j; + oamSetXY(OAM_PLAYER, 0, OFFSCREEN_Y); + for (i = 0; i < MAX_ENEMIES; i++) oamSetXY(OAM_ENEMY(i), 0, OFFSCREEN_Y); + for (j = 0; j < NUM_BULLETS; j++) oamSetXY(OAM_BULLET(j), 0, OFFSCREEN_Y); + oamSetXY(OAM_POWERUP, 0, OFFSCREEN_Y); + bgSetDisable(2); +} + +// Wipe all round state and drop back into a fresh game -- shared by the +// game-over and game-complete screens' "press START to restart" handling. +void resetGame(short *px, short *py) +{ + unsigned char i, j; + score = 0; lastScore = 0xFFFF; // force HUD redraw + lives = START_LIVES; lastLives = 0xFF; + bombs = START_BOMBS; lastBombs = 0xFF; + playerFlash = 0; + powerupActive = 0; + killsSinceDrop = 0; dropThreshold = 10; + *px = 112; *py = 170; // player back at the start + activeEnemies = START_ENEMIES; // back to the gentle opening wave + nextEnemyScore = ENEMIES_PER_LEVEL; // reset the difficulty ramp + for (i = 0; i < MAX_ENEMIES; i++) + { + enemyType[i] = ENEMY_NORMAL; + enemyHealth[i] = 1; + enemyHurt[i] = 0; + if (i < activeEnemies) + spawnEnemy(i); // fresh active enemies (sets gfx/palette) + else + oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); // park the rest + } + for (j = 0; j < NUM_BULLETS; j++) bulletActive[j] = 0; +} + // Intro splash screen: title + a "START GAME" menu item over the drifting // starfield, with a looping marimba fanfare. Blocks until the player presses // START, then wipes its text so nothing bleeds into the playfield. Assumes the @@ -431,6 +548,7 @@ int main(void) // do this before loading any sounds, as in the PVSnesLib likemario sample. spcAllocateSoundRegion(16); spcStop(); + spcLoad(MOD_SFX); // effects bank (soundbank module 0) for (i = 0; i < NUM_SFX; i++) spcLoadEffect(i); // Register the gunshot BRR sample (vol 15, centre pan, pitch 4) as sound 0. spcSetSoundEntry(15, PAN_CENTER, 4, @@ -541,28 +659,9 @@ int main(void) { if (down0 & KEY_START) { - score = 0; lastScore = 0xFFFF; // force HUD redraw - lives = START_LIVES; lastLives = 0xFF; - bombs = START_BOMBS; lastBombs = 0xFF; - playerFlash = 0; - powerupActive = 0; - killsSinceDrop = 0; dropThreshold = 10; - x = 112; y = 170; // player back at the start - activeEnemies = START_ENEMIES; // back to the gentle opening wave - nextEnemyScore = ENEMIES_PER_LEVEL; // reset the difficulty ramp - for (i = 0; i < MAX_ENEMIES; i++) - { - enemyType[i] = ENEMY_NORMAL; - enemyHealth[i] = 1; - enemyHurt[i] = 0; - if (i < activeEnemies) - spawnEnemy(i); // fresh active enemies (sets gfx/palette) - else - oamSet(OAM_ENEMY(i), 0, OFFSCREEN_Y, 2, 0, 0, GFX_ENEMY, 1); // park the rest - } - for (j = 0; j < NUM_BULLETS; j++) bulletActive[j] = 0; consoleDrawText(11, 13, " "); // erase "GAME OVER" consoleDrawText(10, 15, " "); // erase "PRESS START" + resetGame(&x, &y); gameOver = 0; } spcProcess(); @@ -570,6 +669,107 @@ int main(void) continue; } + // Score hit WIN_SCORE: a short freeze, a "CONGRATULATIONS" banner, then + // the screen clears and the credits crawl until START is pressed. + if (gameWon) + { + if (down0 & KEY_START) + { + // Skip straight to a fresh round. Both erases are safe no-ops + // if that particular screen was never drawn in the first place. + consoleDrawText(8, 12, " "); // erase "CONGRATULATIONS" + eraseCredits(); + creditsScroll = 0; + bgSetScroll(0, 0, 0); // BG0 back to its normal HUD position + consoleDrawText(1, 1, "SCORE"); // HUD labels were hidden for the crawl + consoleDrawText(13, 1, "LIVES"); + consoleDrawText(21, 1, "BOMBS"); + bgSetEnable(2); // bring the starfield back + // Loading a module wipes SPC RAM, so switch back to the effects + // bank and re-register everything exactly as at startup: the + // effect instruments, then the raw gunshot BRR sample. + spcStop(); + spcLoad(MOD_SFX); + for (i = 0; i < NUM_SFX; i++) spcLoadEffect(i); + spcSetSoundEntry(15, PAN_CENTER, 4, + (u16)(&gunshot_brrend - &gunshot_brr), &gunshot_brr, &gunSound); + resetGame(&x, &y); + gameWon = 0; + } + else if (winPhase == WIN_PHASE_PAUSE) + { + // Brief freeze on the finishing frame before the banner appears. + if (winTimer > 0) winTimer--; + else + { + consoleDrawText(8, 12, "CONGRATULATIONS"); + spcStop(); + spcLoad(MOD_SUMMERGAMES); + spcPlay(0); + musicFrame = 0; + winPhase = WIN_PHASE_CONGRATS; + winTimer = WIN_CONGRATS_FRAMES; + } + } + else if (winPhase == WIN_PHASE_CONGRATS) + { + // Banner stays up over the still-frozen field for ~5 seconds, + // then the screen clears and the crawl takes over. Keep timing + // the music through this phase too, so the loop restart (once + // WIN_PHASE_CREDITS is reached) lands at the right point. + musicFrame++; + if (winTimer > 0) winTimer--; + else + { + consoleDrawText(8, 12, " "); // erase "CONGRATULATIONS" + consoleDrawText(1, 1, " "); // hide the HUD so it doesn't scroll + consoleDrawText(7, 1, " "); // with the credits (BG0 is shared) + consoleDrawText(13, 1, " "); + consoleDrawText(19, 1, " "); + consoleDrawText(21, 1, " "); + consoleDrawText(27, 1, " "); + clearPlayfield(); + creditsScroll = 0; + creditsPause = CREDITS_START_DELAY_FRAMES; + creditsFrac = 0; + winPhase = WIN_PHASE_CREDITS; + } + } + else // WIN_PHASE_CREDITS + { + // MOD_SUMMERGAMES has no baked-in loop point (see + // MUSIC_LOOP_FRAMES' comment), so restart it by hand once its + // known length has played out. + if (++musicFrame >= MUSIC_LOOP_FRAMES) + { + musicFrame = 0; + spcPlay(0); + } + + // Blank pause first, then the crawl text is drawn once and + // starts drifting down. A sub-pixel accumulator (see + // CREDITS_SCROLL_SPEED) paces the 1px steps evenly. + if (creditsPause > 0) + { + creditsPause--; + if (creditsPause == 0) drawCredits(); + } + else + { + unsigned short acc = creditsFrac + (unsigned short)CREDITS_SCROLL_SPEED; + if (acc >> 8) + { + creditsScroll -= 1; // drift down, like the starfield's fall + bgSetScroll(0, 0, creditsScroll); + } + creditsFrac = (unsigned char)(acc & 0xFF); + } + } + spcProcess(); + WaitForVBlank(); + continue; + } + if (pad0 & KEY_LEFT && x > 0) x -= 2; if (pad0 & KEY_RIGHT && x < SCREEN_W - PLAYER_SIZE) x += 2; // 256 - 21 if (pad0 & KEY_UP && y > PLAYFIELD_TOP) y -= 2; // stay below stats bar @@ -658,8 +858,23 @@ int main(void) } } + // --- Win check: score crossed WIN_SCORE (from a bullet kill above, or + // the Y-bomb earlier this frame). The field stays visible, frozen, + // for a short beat -- the win sequence's own state machine (see the + // gameWon block above the movement code) handles the banner, the + // screen clear, and the credits crawl from here. --- + if (!gameWon && score >= WIN_SCORE) + { + gameWon = 1; + winPhase = WIN_PHASE_PAUSE; + winTimer = WIN_PAUSE_FRAMES; + sfxPowerup(); // bright fanfare for winning + } + // --- Collisions: player (21x21) vs enemy. Costs a life + flash; brief i-frames --- - if (playerFlash == 0) + // Skipped once gameWon just triggered above -- winning ends the round outright, + // so a same-frame hit can't also set gameOver and leave the two states tangled. + if (playerFlash == 0 && !gameWon) { for (i = 0; i < activeEnemies; i++) { diff --git a/tools/make_music_it.py b/tools/make_music_it.py new file mode 100644 index 0000000..5f61deb --- /dev/null +++ b/tools/make_music_it.py @@ -0,0 +1,315 @@ +#!/usr/bin/env python3 +"""Convert music/summergames.mid into res/summergames.it for smconv. + +PVSnesLib's audio converter (smconv) only accepts Impulse Tracker (.it) +modules, not raw MIDI. This script hand-parses the MIDI (no external +dependencies -- none are installed in this environment) and hand-writes a +minimal, valid .it: no instrument structures (sample-direct/"old style" +mode), two synthesized single-cycle waveforms (one per MIDI track), and +uncompressed signed 8-bit PCM samples. The binary layout follows the +ITFileHeader (192 bytes) / ITSample (80 bytes) structs and the pattern +row-compression scheme exactly as documented in OpenMPT's own IT loader +(soundlib/ITTools.h, soundlib/Load_it.cpp). + +Looping is intentionally NOT baked into the module (no Bxx pattern-jump +effect) -- the game code restarts playback with spcPlay(0) once the known +song duration elapses, which avoids needing to get IT's effect-column byte +encoding exactly right. +""" +import struct +import math + +MIDI_PATH = "music/summergames.mid" +OUT_PATH = "res/summergames.it" + +TICKS_PER_ROW = 256 # 1 row = 1 sixteenth note at division=1024 +ROWS_PER_PATTERN = 64 +IT_TEMPO = 160 # BPM +IT_SPEED = 3 # ticks/row -> seconds/row = 2.5*speed/tempo = 0.046875s + # (half of the original 6 -- doubles playback rate with + # no pitch shift, since it's sequenced tempo, not audio) +MIDDLE_C_HZ = 261.6256 # MIDI note 60 + + +# --- MIDI parsing (pure Python, no deps) ------------------------------------ + +def read_varlen(data, i): + val = 0 + while True: + b = data[i]; i += 1 + val = (val << 7) | (b & 0x7F) + if not (b & 0x80): + break + return val, i + + +def read_chunks(data): + i = 0 + chunks = [] + while i < len(data): + cid = data[i:i + 4] + clen = struct.unpack(">I", data[i + 4:i + 8])[0] + chunks.append((cid, data[i + 8:i + 8 + clen])) + i += 8 + clen + return chunks + + +def parse_midi(path): + with open(path, "rb") as f: + data = f.read() + chunks = read_chunks(data) + fmt, ntrks, division = struct.unpack(">HHH", chunks[0][1][:6]) + + tracks = [] # list of (name, [(start_tick, end_tick, note, vel), ...]) + for cid, body in chunks[1:]: + if cid != b"MTrk": + continue + i = 0 + abs_tick = 0 + last_status = None + notes_on = {} + events = [] + name = "" + while i < len(body): + delta, i = read_varlen(body, i) + abs_tick += delta + status = body[i] + if status == 0xFF: + i += 1 + meta = body[i]; i += 1 + length, i = read_varlen(body, i) + if meta == 0x03: + name = body[i:i + length].decode("latin1", "ignore") + i += length + elif status in (0xF0, 0xF7): + i += 1 + length, i = read_varlen(body, i) + i += length + else: + if status & 0x80: + i += 1 + last_status = status + else: + status = last_status + kind = status & 0xF0 + if kind == 0xC0: + i += 1 + elif kind == 0xD0: + i += 1 + else: + d1, d2 = body[i], body[i + 1]; i += 2 + if kind == 0x90 and d2 > 0: + notes_on[d1] = (abs_tick, d2) + elif kind == 0x80 or (kind == 0x90 and d2 == 0): + if d1 in notes_on: + start, vel = notes_on.pop(d1) + events.append((start, abs_tick, d1, vel)) + if events: + events.sort(key=lambda e: e[0]) + tracks.append((name, events)) + return division, tracks + + +# --- Channel assignment ------------------------------------------------------ + +def assign_channels(tracks): + """Returns (num_channels, list of (channel, instrument_idx, events)).""" + channel_tracks = [] # (base_channel, num_channels_for_track, events, instrument_idx) + next_channel = 0 + for instr_idx, (name, events) in enumerate(tracks, start=1): + max_poly = 0 + active = [] + for start, end, note, vel in events: + active = [a for a in active if a > start] + active.append(end) + max_poly = max(max_poly, len(active)) + n_ch = min(max_poly, 2) if max_poly > 0 else 1 + channel_tracks.append((next_channel, n_ch, events, instr_idx)) + next_channel += n_ch + return next_channel, channel_tracks + + +def build_channel_events(channel_tracks): + """Round-robins overlapping notes within a track's allotted channels. + Returns dict: channel -> list of (row, note, instr, vol).""" + result = {} + for base_ch, n_ch, events, instr_idx in channel_tracks: + chan_free_at = [0] * n_ch # tick each sub-channel becomes free + for start, end, note, vel in events: + # pick the sub-channel that's been free longest (round-robin/least-recently-used) + sub = min(range(n_ch), key=lambda k: chan_free_at[k]) + chan_free_at[sub] = end + ch = base_ch + sub + row = start // TICKS_PER_ROW + vol = max(1, min(64, vel // 2)) + note = max(0, min(119, note)) + result.setdefault(ch, []).append((row, note, instr_idx, vol)) + return result + + +# --- Waveform synthesis ------------------------------------------------------- + +def make_pulse(length=32, duty=0.25, amp=100): + on = max(1, int(length * duty)) + return [amp if i < on else -amp for i in range(length)] + + +def make_triangle(length=32, amp=100): + out = [] + for i in range(length): + phase = i / length + v = 4 * abs(phase - 0.5) - 1 # -1..1 triangle, starts at 0 rising... adjust: + out.append(int(round(v * amp))) + return out + + +# --- IT file writing ----------------------------------------------------------- + +def pack_sample_header(name, cvt_signed, flags, vol, length, loop_end, c5speed, samplepointer): + return struct.pack( + "<4s13sBBB26sBBIIIIIIIBBBB", + b"IMPS", name.encode("ascii")[:12].ljust(13, b"\x00"), + 64, # gvl (global volume) + flags, # flags + vol, # default volume (0..64) + b"".ljust(26, b"\x00"), + 0x01 if cvt_signed else 0x00, # cvt + 32, # dfp (default pan, unused -- panning set per channel) + length, 0, loop_end, # length, loopbegin, loopend + c5speed, + 0, 0, # susloopbegin, susloopend + samplepointer, + 0, 0, 0, 0, # vis, vid, vir, vit + ) + + +def encode_pattern(rows_events, num_rows): + """rows_events: dict row_idx -> {channel: (note, instr, vol)}""" + data = bytearray() + established = set() + for row in range(num_rows): + events = rows_events.get(row) + if not events: + data.append(0) + continue + for ch in sorted(events.keys()): + note, instr, vol = events[ch] + if ch not in established: + data.append((ch + 1) | 0x80) + data.append(0x07) # mask: note+instr+vol values follow, every time + established.add(ch) + else: + data.append(ch + 1) + data.append(note) + data.append(instr) + data.append(vol) + data.append(0) + return bytes(data) + + +def write_it(path, num_channels, channel_events, samples, total_rows): + num_patterns = (total_rows + ROWS_PER_PATTERN - 1) // ROWS_PER_PATTERN + + # Build each pattern's row->{channel:(note,instr,vol)} map + pattern_rows = [dict() for _ in range(num_patterns)] + for ch, events in channel_events.items(): + for row, note, instr, vol in events: + pat = row // ROWS_PER_PATTERN + local_row = row % ROWS_PER_PATTERN + pattern_rows[pat].setdefault(local_row, {})[ch] = (note, instr, vol) + + pattern_blobs = [] + for pat in range(num_patterns): + rows_in_pat = min(ROWS_PER_PATTERN, total_rows - pat * ROWS_PER_PATTERN) + body = encode_pattern(pattern_rows[pat], rows_in_pat) + header = struct.pack("