spectrumizer 0.1.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.
@@ -0,0 +1,13 @@
1
+ """spectrumizer — generate ZX Spectrum AY (PT3) music from public sources.
2
+
3
+ Pipeline: source -> IR (ir.Song) -> arrange (3 AY channels) -> PT3 bytes.
4
+
5
+ LICENCE GUARDRAIL: spectrumizer does NOT launder licences. The licence of the
6
+ SOURCE governs the OUTPUT. Only public-domain or your own material is safe to
7
+ bundle into a game. See LICENSING.md.
8
+ """
9
+
10
+ from .ir import Song, Note
11
+
12
+ __all__ = ["Song", "Note"]
13
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Enable `python -m spectrumizer ...`."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ raise SystemExit(main())
@@ -0,0 +1,126 @@
1
+ """Arranger: IR Song -> PT3 bytes.
2
+
3
+ Channel allocation (3 AY channels, A governs pattern length so it gets the lead):
4
+ A = lead (top voice)
5
+ B = bass (bottom voice)
6
+ C = real drums if the source has them; else synth drums in chiptune style;
7
+ else the harmony (middle voice) in faithful style.
8
+
9
+ `--style faithful|chiptune` selects which passes run — same engine, composable
10
+ passes, not two code paths.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from ..ir import Song, Note
16
+ from ..pt3 import (
17
+ midi_to_pt3_byte, NOTE_TO_BYTE, build_pt3,
18
+ DEFAULT_SAMPLES, DEFAULT_ORNAMENTS,
19
+ S_LEAD, S_BASS, S_HARMONY, S_SNARE, S_KICK, ORN_EMPTY,
20
+ )
21
+ from .model import Placed, rasterize, pack_patterns, ROWS_PER_PATTERN
22
+ from .quantize import plan_grid, note_rows
23
+ from .reduce import assign_voices
24
+ from .embellish import octave_short_lead, synth_drums
25
+
26
+ # GM percussion keys we treat as a kick (everything else on ch10 -> snare).
27
+ GM_KICK_KEYS = {35, 36}
28
+ DRUM_NOTE_BYTE = NOTE_TO_BYTE['C-4'] # dummy pitch; drum samples mute tone
29
+
30
+
31
+ def vol_from_velocity(velocity: int, ceil: int, vmax: int) -> int:
32
+ """Map a MIDI velocity to an AY volume in 1..ceil, scaled so the piece's
33
+ loudest note reaches the channel's ceiling. `vmax <= 0` disables dynamics
34
+ (returns the ceiling), so a flat-velocity source stays at full volume."""
35
+ if vmax <= 0:
36
+ return ceil
37
+ return max(1, min(ceil, round(ceil * velocity / vmax)))
38
+
39
+
40
+ def _line_to_placed(line: list[Note], rows_per_beat: int, total_rows: int,
41
+ transpose: int, ceil_vol: int = 15, vmax: int = 0) -> list[Placed]:
42
+ out: list[Placed] = []
43
+ for n in line:
44
+ s, e = note_rows(n.start, n.end, rows_per_beat, total_rows)
45
+ if s >= total_rows:
46
+ continue
47
+ opts = {} if vmax <= 0 else {'vol': vol_from_velocity(n.velocity, ceil_vol, vmax)}
48
+ out.append(Placed(s, e, midi_to_pt3_byte(n.pitch, transpose), opts))
49
+ return out
50
+
51
+
52
+ def _drums_to_placed(drums: list[Note], rows_per_beat: int, total_rows: int,
53
+ ceil_vol: int = 13, vmax: int = 0) -> list[Placed]:
54
+ out: list[Placed] = []
55
+ for n in drums:
56
+ s, _ = note_rows(n.start, n.end, rows_per_beat, total_rows)
57
+ if s >= total_rows:
58
+ continue
59
+ sample = S_KICK if (n.pitch in GM_KICK_KEYS or n.pitch <= 36) else S_SNARE
60
+ opts = {'sample': sample}
61
+ if vmax > 0:
62
+ opts['vol'] = vol_from_velocity(n.velocity, ceil_vol, vmax)
63
+ out.append(Placed(s, s + 1, DRUM_NOTE_BYTE, opts))
64
+ return out
65
+
66
+
67
+ def arrange(song: Song, *, style: str = 'faithful', rows_per_beat: int = 4,
68
+ speed: int | None = None, transpose: int = 0,
69
+ name: str | None = None, author: str = 'SPECTRUMIZER',
70
+ loop_pos: int = 0, dynamics: bool = True) -> tuple[bytes, dict]:
71
+ """Arrange `song` and return (pt3_bytes, stats).
72
+
73
+ `dynamics`: map MIDI velocity to per-note AY volume (on by default); the
74
+ loudest note in the piece sits at each channel's ceiling. False = flat
75
+ per-channel volume.
76
+ """
77
+ speed_v, total_rows = plan_grid(song, rows_per_beat, speed)
78
+
79
+ use_drum_channel = song.has_drums or style == 'chiptune'
80
+ n_pitched = 2 if use_drum_channel else 3
81
+ lead, bass, harmony = assign_voices(song.notes, n_pitched)
82
+
83
+ vmax = max((n.velocity for n in song.notes), default=0) if dynamics else 0
84
+ lead_p = _line_to_placed(lead, rows_per_beat, total_rows, transpose, 15, vmax)
85
+ bass_p = _line_to_placed(bass, rows_per_beat, total_rows, transpose, 14, vmax)
86
+
87
+ if song.has_drums:
88
+ dmax = max((n.velocity for n in song.drums), default=0) if dynamics else 0
89
+ c_p = _drums_to_placed(song.drums, rows_per_beat, total_rows, 13, dmax)
90
+ c_sample, c_vol, c_kind = S_SNARE, 13, 'drums'
91
+ elif style == 'chiptune':
92
+ c_p = synth_drums(total_rows, rows_per_beat, DRUM_NOTE_BYTE, S_SNARE, S_KICK)
93
+ c_sample, c_vol, c_kind = S_SNARE, 13, 'synth-drums'
94
+ else:
95
+ c_p = _line_to_placed(harmony, rows_per_beat, total_rows, transpose, 10, vmax)
96
+ c_sample, c_vol, c_kind = S_HARMONY, 10, 'harmony'
97
+
98
+ if style == 'chiptune':
99
+ octave_short_lead(lead_p, short_thresh_rows=rows_per_beat)
100
+
101
+ specs = [
102
+ (rasterize(lead_p, total_rows), S_LEAD, 15, ORN_EMPTY),
103
+ (rasterize(bass_p, total_rows), S_BASS, 14, ORN_EMPTY),
104
+ (rasterize(c_p, total_rows), c_sample, c_vol, ORN_EMPTY),
105
+ ]
106
+ patterns = pack_patterns(specs, total_rows)
107
+
108
+ pt3 = build_pt3(patterns, dict(DEFAULT_SAMPLES), dict(DEFAULT_ORNAMENTS),
109
+ name=name or song.name or "SPECTRUMIZED",
110
+ author=author, speed=speed_v, loop_pos=loop_pos)
111
+
112
+ stats = {
113
+ 'style': style,
114
+ 'dynamics': dynamics,
115
+ 'speed': speed_v,
116
+ 'rows_per_beat': rows_per_beat,
117
+ 'total_rows': total_rows,
118
+ 'patterns': len(patterns),
119
+ 'tempo_bpm': round(song.tempo_bpm, 1),
120
+ 'voices': {'lead': len(lead), 'bass': len(bass),
121
+ 'channel_c': c_kind,
122
+ 'harmony': len(harmony) if c_kind == 'harmony' else 0,
123
+ 'drums': len(song.drums)},
124
+ 'bytes': len(pt3),
125
+ }
126
+ return pt3, stats
@@ -0,0 +1,38 @@
1
+ """Chiptune embellishers (only run for --style chiptune).
2
+
3
+ MVP passes:
4
+ * octave_short_lead — octave-double SHORT lead notes (the classic AY brightener;
5
+ held notes stay single-voice so they don't warble).
6
+ * synth_drums — a backbeat (kick on beats 1&3, snare on 2&4) when the
7
+ source has no drum track, so chiptune output still has percussive drive.
8
+
9
+ Planned next: chord arpeggios via ORN_MAJOR/ORN_MINOR (the ornament builders are
10
+ already wired in pt3.ornaments) to fake polyphony on a single channel.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from .model import Placed
16
+ from ..pt3 import ORN_OCTAVE, ORN_EMPTY
17
+
18
+
19
+ def octave_short_lead(lead: list[Placed], short_thresh_rows: int) -> None:
20
+ """In place: octave ornament on notes shorter than the threshold."""
21
+ for p in lead:
22
+ p.opts['ornament'] = ORN_OCTAVE if (p.end - p.start) < short_thresh_rows else ORN_EMPTY
23
+
24
+
25
+ def synth_drums(total_rows: int, rows_per_beat: int, drum_byte: int,
26
+ snare_sample: int, kick_sample: int) -> list[Placed]:
27
+ """A simple 4/4 backbeat occupying one channel (drums = noise, tone off)."""
28
+ placed: list[Placed] = []
29
+ n_beats = total_rows // rows_per_beat
30
+ for b in range(n_beats):
31
+ row = b * rows_per_beat
32
+ if row >= total_rows:
33
+ break
34
+ if b % 4 in (0, 2):
35
+ placed.append(Placed(row, row + 1, drum_byte, {'sample': kick_sample}))
36
+ else:
37
+ placed.append(Placed(row, row + 1, drum_byte, {'sample': snare_sample}))
38
+ return placed
@@ -0,0 +1,74 @@
1
+ """Row-grid model + the bridge to the PT3 channel packer.
2
+
3
+ A `Placed` note is a pitched/percussive event already quantised to integer rows.
4
+ `rasterize` turns a channel's Placed notes into a per-row cell list (REST / OFF /
5
+ note / (note, opts)); `pack_patterns` slices the song into fixed-size patterns
6
+ and encodes each channel, enforcing the player's two hard invariants (see
7
+ pt3.encode): every channel of a pattern encodes exactly ROWS_PER_PATTERN rows,
8
+ and row 0 is never a dropped leading rest.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ from ..pt3 import encode_channel, REST, OFF
16
+
17
+ ROWS_PER_PATTERN = 64
18
+
19
+
20
+ @dataclass
21
+ class Placed:
22
+ start: int # start row (inclusive)
23
+ end: int # end row (exclusive)
24
+ note: int # PT3 note byte
25
+ opts: dict = field(default_factory=dict) # vol / ornament / sample
26
+
27
+
28
+ def rasterize(placed: list[Placed], total_rows: int) -> list:
29
+ """Channel Placed notes -> per-row cells of length total_rows."""
30
+ cells: list = [REST] * total_rows
31
+ placed = sorted(placed, key=lambda p: p.start)
32
+ for idx, p in enumerate(placed):
33
+ if p.start >= total_rows:
34
+ continue
35
+ cells[p.start] = (p.note, dict(p.opts)) if p.opts else p.note
36
+ nxt = placed[idx + 1].start if idx + 1 < len(placed) else total_rows
37
+ # cut the note (OFF) when there is a real gap before the next onset.
38
+ if p.end < nxt and 0 <= p.end < total_rows and cells[p.end] == REST:
39
+ cells[p.end] = OFF
40
+ return cells
41
+
42
+
43
+ def pack_patterns(specs: list[tuple], total_rows: int,
44
+ rows_per_pattern: int = ROWS_PER_PATTERN
45
+ ) -> list[tuple[bytes, bytes, bytes]]:
46
+ """Encode 3 channels into a list of (A, B, C) byte triples.
47
+
48
+ specs: 3 tuples (cells, default_sample, default_volume, default_ornament),
49
+ one per AY channel, each `cells` of length total_rows. A held note that
50
+ crosses a pattern boundary is re-attacked at row 0 (pitch continuity);
51
+ genuine silence at row 0 is anchored with OFF so the packer doesn't drop it.
52
+ """
53
+ assert total_rows % rows_per_pattern == 0
54
+ n_pat = total_rows // rows_per_pattern
55
+ last_note: list = [None, None, None]
56
+ patterns: list[tuple[bytes, bytes, bytes]] = []
57
+
58
+ for pi in range(n_pat):
59
+ chans: list[bytes] = []
60
+ for ci, (cells, sample, vol, orn) in enumerate(specs):
61
+ sl = list(cells[pi * rows_per_pattern:(pi + 1) * rows_per_pattern])
62
+ if sl and sl[0] == REST:
63
+ sl[0] = last_note[ci] if last_note[ci] is not None else OFF
64
+ for cell in sl:
65
+ if cell == REST:
66
+ continue
67
+ if cell == OFF:
68
+ last_note[ci] = None
69
+ else:
70
+ last_note[ci] = cell[0] if isinstance(cell, tuple) else cell
71
+ chans.append(encode_channel(sl, default_sample=sample,
72
+ default_volume=vol, ornament=orn))
73
+ patterns.append((chans[0], chans[1], chans[2]))
74
+ return patterns
@@ -0,0 +1,36 @@
1
+ """Time -> PT3 row grid.
2
+
3
+ PT3 has no tempo field beyond `speed` (frames per row). At the Spectrum's 50 Hz
4
+ interrupt, rows/sec = bpm*rows_per_beat/60, so frames/row = 3000/(bpm*rpb).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+
11
+ from ..ir import Song
12
+ from .model import ROWS_PER_PATTERN
13
+
14
+
15
+ def plan_grid(song: Song, rows_per_beat: int = 4, speed: int | None = None,
16
+ rows_per_pattern: int = ROWS_PER_PATTERN) -> tuple[int, int]:
17
+ """Return (speed, total_rows). total_rows is rounded up to a whole number
18
+ of patterns."""
19
+ bpm = song.tempo_bpm or 120.0
20
+ if speed is None:
21
+ speed = round(3000.0 / (bpm * rows_per_beat))
22
+ speed = max(1, min(31, speed))
23
+
24
+ rows = math.ceil(song.length_beats * rows_per_beat)
25
+ rows = max(rows, rows_per_pattern)
26
+ rows = ((rows + rows_per_pattern - 1) // rows_per_pattern) * rows_per_pattern
27
+ return speed, rows
28
+
29
+
30
+ def note_rows(start_beat: float, end_beat: float, rows_per_beat: int,
31
+ total_rows: int) -> tuple[int, int]:
32
+ s = round(start_beat * rows_per_beat)
33
+ e = round(end_beat * rows_per_beat)
34
+ s = max(0, min(total_rows - 1, s))
35
+ e = max(s + 1, min(total_rows, e))
36
+ return s, e
@@ -0,0 +1,48 @@
1
+ """Polyphony reduction: many source voices -> at most 3 monophonic AY lines.
2
+
3
+ `extract_line` peels one monophonic voice off a note set with a high/low
4
+ preference (a greedy "skyline"): when two notes overlap, the preferred one wins
5
+ and truncates the other. Calling it top-then-bottom-then-top yields lead / bass /
6
+ harmony, with everything else dropped.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import dataclasses
12
+
13
+ from ..ir import Note
14
+
15
+
16
+ def extract_line(notes: list[Note], prefer: str = 'high'
17
+ ) -> tuple[list[Note], list[Note]]:
18
+ """Return (line, leftover). `line` is monophonic (non-overlapping)."""
19
+ work = sorted((dataclasses.replace(n) for n in notes),
20
+ key=lambda n: (n.start, n.pitch))
21
+ line: list[Note] = []
22
+ leftover: list[Note] = []
23
+ for n in work:
24
+ if line and n.start < line[-1].end - 1e-9:
25
+ prev = line[-1]
26
+ better = (n.pitch > prev.pitch) if prefer == 'high' else (n.pitch < prev.pitch)
27
+ if better:
28
+ if n.start - prev.start > 1e-9:
29
+ prev.dur = n.start - prev.start # truncate the loser
30
+ line.append(n)
31
+ else:
32
+ line.pop(); leftover.append(prev); line.append(n)
33
+ else:
34
+ leftover.append(n)
35
+ else:
36
+ line.append(n)
37
+ return line, leftover
38
+
39
+
40
+ def assign_voices(notes: list[Note], n_pitched: int
41
+ ) -> tuple[list[Note], list[Note], list[Note]]:
42
+ """Split into (lead, bass, harmony). harmony is empty if n_pitched < 3."""
43
+ lead, rem = extract_line(notes, 'high')
44
+ bass, rem2 = extract_line(rem, 'low')
45
+ harmony: list[Note] = []
46
+ if n_pitched >= 3:
47
+ harmony, _ = extract_line(rem2, 'high')
48
+ return lead, bass, harmony
spectrumizer/audio.py ADDED
@@ -0,0 +1,195 @@
1
+ """AY-3-8910 synthesiser + WAV rendering for PT3 playback.
2
+
3
+ A deliberately small software AY: three square-wave tone generators, one 17-bit
4
+ LFSR noise generator, per-channel tone/noise mixing and a 16-level logarithmic
5
+ DAC. No envelope generator — spectrumizer's samples carry amplitude per tick, so
6
+ the envelope path is never used. The point is to *audition* a module on any
7
+ machine, not to be a cycle-exact emulator; pitch uses the exact PT3 tone table by
8
+ default (`build_pt3_table`), with an equal-tempered fallback (`tuning='equal'`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ import shutil
15
+ import struct
16
+ import subprocess
17
+ import wave
18
+ from array import array
19
+
20
+ from .pt3.player import Module, iter_frames, FRAME_HZ
21
+
22
+ # AY clock on the 128K / +2 (Hz). Tone freq = clock / (16 * period).
23
+ AY_CLOCK = 1773400
24
+ DEFAULT_RATE = 44100
25
+
26
+ # 16-level AY (not YM) DAC, normalised 0..1.
27
+ AY_VOL = [
28
+ 0.0, 0.00999, 0.01445, 0.02106, 0.03070, 0.04555, 0.06450, 0.10736,
29
+ 0.12659, 0.20499, 0.29221, 0.37284, 0.49253, 0.63532, 0.80558, 1.0,
30
+ ]
31
+
32
+
33
+ # --- the exact PT3 tone table (the real Spectrum pitches) ---------------------
34
+ # Tone table #1 (the id the writer stamps), taken from the NoteTableCreator data
35
+ # in Sergey Bulba's PT3 player (`T_PACK`, first physical table): the 12 packed
36
+ # octave periods 0x06EC..0x0D10. The player stores them lowest-pitch first, so
37
+ # we reverse to put C (largest period) at index 0, then build the 8 octaves by
38
+ # the player's own successive /2 — `period(note) = base[note%12] >> note//12`.
39
+ # (The player's two ±1 end-corrections are sub-cent and omitted.)
40
+ PT3_T1_OCTAVE = [
41
+ 0x0D10, 0x0C55, 0x0BA4, 0x0AFC, 0x0A5F, 0x09CA, # C C# D D# E F
42
+ 0x093D, 0x08B8, 0x083B, 0x07C5, 0x0755, 0x06EC, # F# G G# A A# B
43
+ ]
44
+
45
+
46
+ def build_pt3_table() -> list:
47
+ """The exact PT3 tone-table-1 periods for note indices 0..95 (0 == C-1)."""
48
+ return [PT3_T1_OCTAVE[n % 12] >> (n // 12) for n in range(96)]
49
+
50
+
51
+ def build_equal_table(clock: int = AY_CLOCK) -> list:
52
+ """Equal-tempered AY tone periods (A4=440) — the legacy approximation."""
53
+ table = []
54
+ for idx in range(96):
55
+ freq = 440.0 * 2.0 ** ((idx + 24 - 69) / 12.0) # idx 0 -> MIDI 24 (C1)
56
+ table.append(max(1, min(4095, round(clock / (16.0 * freq)))))
57
+ return table
58
+
59
+
60
+ _PT3_PERIOD = build_pt3_table()
61
+ _EQ_PERIOD = build_equal_table()
62
+
63
+
64
+ def render_pcm(module: Module, *, sample_rate: int = DEFAULT_RATE,
65
+ loops: int = 1, max_seconds: float | None = None,
66
+ noise_period: int | None = None, tuning: str = 'pt3',
67
+ stereo: str = 'abc', separation: float = 0.7,
68
+ gain: float = 0.9) -> tuple[array, int]:
69
+ """Render a parsed module to 16-bit PCM; returns ``(pcm, channels)``.
70
+
71
+ `tuning`: 'pt3' (default, the exact Spectrum tone table) or 'equal'.
72
+ `noise_period`: None (default) tracks the module's real per-frame AY noise
73
+ register (R6); pass 1..31 to force a fixed period.
74
+ `stereo`: 'abc' (A-left / B-centre / C-right, the classic ZX layout), 'acb',
75
+ or 'mono'. `separation`: 0..1 stereo width (0 = narrow, 1 = hard pan).
76
+ """
77
+ periods = _EQ_PERIOD if tuning == 'equal' else _PT3_PERIOD
78
+ spf = sample_rate // FRAME_HZ # output samples per frame
79
+ pcm = array('h')
80
+
81
+ # Per-channel L/R panning weights for channels A, B, C.
82
+ if stereo == 'mono':
83
+ channels = 1
84
+ pan_l = pan_r = (1.0, 1.0, 1.0)
85
+ norm_l = norm_r = 1.0 / 3.0
86
+ else:
87
+ channels = 2
88
+ sep = max(0.0, min(1.0, separation))
89
+ pos = {'abc': ('L', 'C', 'R'), 'acb': ('L', 'R', 'C')}.get(stereo,
90
+ ('L', 'C', 'R'))
91
+ wl = {'L': 1.0, 'R': 1.0 - sep, 'C': 0.7071}
92
+ wr = {'L': 1.0 - sep, 'R': 1.0, 'C': 0.7071}
93
+ pan_l = tuple(wl[p] for p in pos)
94
+ pan_r = tuple(wr[p] for p in pos)
95
+ norm_l = 1.0 / max(1e-6, sum(pan_l))
96
+ norm_r = 1.0 / max(1e-6, sum(pan_r))
97
+
98
+ phase = [0.0, 0.0, 0.0]
99
+ noise_acc = 0.0
100
+ lfsr = 1
101
+ noise_level = 1.0
102
+
103
+ for frame, noise_r6 in iter_frames(module, loops=loops, max_seconds=max_seconds):
104
+ # Precompute each channel's constant-per-frame parameters.
105
+ ch = []
106
+ for note_idx, amp, tone_on, noise_on in frame:
107
+ if amp <= 0 or note_idx is None:
108
+ ch.append(None)
109
+ continue
110
+ period = periods[note_idx] if 0 <= note_idx < 96 \
111
+ else periods[max(0, min(95, note_idx))]
112
+ inc = (AY_CLOCK / (16.0 * period)) / sample_rate
113
+ ch.append((inc, AY_VOL[amp], tone_on, noise_on))
114
+
115
+ # AY noise period this frame: the module-derived R6 (0 -> hardware 1),
116
+ # or a fixed override. Recomputed per frame so it tracks the song.
117
+ npd = noise_period if noise_period is not None else ((noise_r6 & 0x1F) or 1)
118
+ noise_step = (AY_CLOCK / 16.0 / npd) / sample_rate
119
+
120
+ for _s in range(spf):
121
+ # advance the shared noise LFSR
122
+ noise_acc += noise_step
123
+ while noise_acc >= 1.0:
124
+ noise_acc -= 1.0
125
+ newbit = (lfsr ^ (lfsr >> 3)) & 1
126
+ lfsr = (lfsr >> 1) | (newbit << 16)
127
+ noise_level = float(lfsr & 1)
128
+
129
+ left = right = 0.0
130
+ for ci in range(3):
131
+ c = ch[ci]
132
+ if c is None:
133
+ continue
134
+ inc, vol, tone_on, noise_on = c
135
+ ph = phase[ci] + inc
136
+ if ph >= 1.0:
137
+ ph -= int(ph)
138
+ phase[ci] = ph
139
+ t_eff = 1.0 if (not tone_on or ph < 0.5) else 0.0
140
+ n_eff = 1.0 if (not noise_on or noise_level >= 1.0) else 0.0
141
+ out = vol * t_eff * n_eff
142
+ left += out * pan_l[ci]
143
+ right += out * pan_r[ci]
144
+
145
+ lv = int(left * norm_l * gain * 32767.0)
146
+ pcm.append(-32768 if lv < -32768 else 32767 if lv > 32767 else lv)
147
+ if channels == 2:
148
+ rv = int(right * norm_r * gain * 32767.0)
149
+ pcm.append(-32768 if rv < -32768 else 32767 if rv > 32767 else rv)
150
+
151
+ return pcm, channels
152
+
153
+
154
+ def write_wav(path: str, pcm: array, sample_rate: int = DEFAULT_RATE,
155
+ channels: int = 1) -> None:
156
+ with wave.open(path, 'wb') as w:
157
+ w.setnchannels(channels)
158
+ w.setsampwidth(2)
159
+ w.setframerate(sample_rate)
160
+ w.writeframes(pcm.tobytes() if hasattr(pcm, 'tobytes') else struct.pack(
161
+ f'<{len(pcm)}h', *pcm))
162
+
163
+
164
+ # Players to try, in order; first one found on PATH wins.
165
+ _PLAYERS = (
166
+ ('afplay', []),
167
+ ('ffplay', ['-nodisp', '-autoexit', '-loglevel', 'quiet']),
168
+ ('aplay', ['-q']),
169
+ ('paplay', []),
170
+ ('play', ['-q']), # SoX
171
+ )
172
+
173
+
174
+ def play_wav(path: str) -> bool:
175
+ """Play a WAV with the first available system player. Returns success."""
176
+ for exe, flags in _PLAYERS:
177
+ found = shutil.which(exe)
178
+ if found:
179
+ subprocess.run([found, *flags, path], check=False)
180
+ return True
181
+ return False
182
+
183
+
184
+ def duration_seconds(module: Module, *, loops: int = 1,
185
+ max_seconds: float | None = None) -> float:
186
+ """Total play length in seconds (cheap: sums rows, no synthesis)."""
187
+ if max_seconds is not None:
188
+ return float(max_seconds)
189
+ speed = module.speed
190
+ frames = 0
191
+ from .pt3.player import _positions
192
+ for pidx in _positions(module, loops, unbounded=False):
193
+ if pidx < len(module.patterns):
194
+ frames += max(len(c) for c in module.patterns[pidx]) * speed
195
+ return frames / FRAME_HZ
spectrumizer/cli.py ADDED
@@ -0,0 +1,102 @@
1
+ """spectrumizer CLI: MIDI -> PT3."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+
10
+ LICENCE_REMINDER = (
11
+ "reminder: the OUTPUT inherits the SOURCE's licence. Only bundle public-domain "
12
+ "or your own music into a release. See spectrumizer/LICENSING.md."
13
+ )
14
+
15
+
16
+ def build_parser() -> argparse.ArgumentParser:
17
+ p = argparse.ArgumentParser(
18
+ prog="spectrumizer",
19
+ description="Generate ZX Spectrum AY (PT3) music from a MIDI source.")
20
+ p.add_argument("input", help="input .mid / .midi file")
21
+ p.add_argument("-o", "--output", help="output .pt3 (default: input with .pt3)")
22
+ p.add_argument("--style", choices=["faithful", "chiptune"], default="faithful",
23
+ help="faithful 3-voice reduction, or chiptune embellishments "
24
+ "(octave leads + synth drums). Default: faithful.")
25
+ p.add_argument("--rows-per-beat", type=int, default=4,
26
+ help="grid resolution (4 = sixteenth notes). Default: 4.")
27
+ p.add_argument("--speed", type=int, default=None,
28
+ help="PT3 speed (frames/row). Default: derived from tempo.")
29
+ p.add_argument("--transpose", type=int, default=0,
30
+ help="semitones to shift all pitches (tune AY octave by ear).")
31
+ p.add_argument("--name", default=None, help="PT3 module title (<=32 chars).")
32
+ p.add_argument("--author", default="SPECTRUMIZER",
33
+ help="PT3 module author (<=32 chars).")
34
+ p.add_argument("--loop-pos", type=int, default=0,
35
+ help="position to loop back to after the song ends. Default 0.")
36
+ p.add_argument("--no-dynamics", dest="dynamics", action="store_false",
37
+ help="flat per-channel volume instead of mapping MIDI velocity "
38
+ "to AY volume (dynamics are on by default).")
39
+ p.add_argument("--play", action="store_true",
40
+ help="after writing the .pt3, render it to audio and play it "
41
+ "(software AY). See also the `spectrumizer-play` command.")
42
+ p.add_argument("-q", "--quiet", action="store_true",
43
+ help="suppress the stats summary.")
44
+ return p
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ args = build_parser().parse_args(argv)
49
+
50
+ if not os.path.isfile(args.input):
51
+ print(f"spectrumizer: input not found: {args.input}", file=sys.stderr)
52
+ return 2
53
+
54
+ out = args.output or (os.path.splitext(args.input)[0] + ".pt3")
55
+
56
+ # Imported lazily so `--help` works without mido installed.
57
+ from .inputs.midi import load_midi
58
+ from .arrange import arrange
59
+
60
+ song = load_midi(args.input)
61
+ if not song.notes and not song.drums:
62
+ print("spectrumizer: no notes found in input.", file=sys.stderr)
63
+ return 1
64
+
65
+ pt3, stats = arrange(
66
+ song, style=args.style, rows_per_beat=args.rows_per_beat,
67
+ speed=args.speed, transpose=args.transpose,
68
+ name=args.name, author=args.author, loop_pos=args.loop_pos,
69
+ dynamics=args.dynamics)
70
+
71
+ with open(out, "wb") as f:
72
+ f.write(pt3)
73
+
74
+ if not args.quiet:
75
+ v = stats["voices"]
76
+ print(f"spectrumizer: {args.input} -> {out}")
77
+ print(f" style={stats['style']} speed={stats['speed']} "
78
+ f"tempo~{stats['tempo_bpm']}bpm patterns={stats['patterns']} "
79
+ f"bytes={stats['bytes']}")
80
+ print(f" A=lead({v['lead']}) B=bass({v['bass']}) "
81
+ f"C={v['channel_c']}"
82
+ + (f"({v['harmony']})" if v['channel_c'] == 'harmony' else "")
83
+ + (f" drums={v['drums']}" if v['drums'] else ""))
84
+ print(f" {LICENCE_REMINDER}")
85
+
86
+ if args.play:
87
+ from .pt3.player import parse_module
88
+ from . import audio
89
+ module = parse_module(pt3)
90
+ wav = os.path.splitext(out)[0] + ".wav"
91
+ pcm, channels = audio.render_pcm(module)
92
+ audio.write_wav(wav, pcm, channels=channels)
93
+ if not args.quiet:
94
+ print(f" playing {wav} ...")
95
+ if not audio.play_wav(wav):
96
+ print(f"spectrumizer: no system audio player found; open {wav} manually.",
97
+ file=sys.stderr)
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Input adapters: source format -> IR (spectrumizer.ir.Song)."""