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.
- spectrumizer/__init__.py +13 -0
- spectrumizer/__main__.py +6 -0
- spectrumizer/arrange/__init__.py +126 -0
- spectrumizer/arrange/embellish.py +38 -0
- spectrumizer/arrange/model.py +74 -0
- spectrumizer/arrange/quantize.py +36 -0
- spectrumizer/arrange/reduce.py +48 -0
- spectrumizer/audio.py +195 -0
- spectrumizer/cli.py +102 -0
- spectrumizer/inputs/__init__.py +1 -0
- spectrumizer/inputs/midi.py +81 -0
- spectrumizer/inputs/musicxml.py +21 -0
- spectrumizer/ir.py +51 -0
- spectrumizer/play.py +86 -0
- spectrumizer/pt3/__init__.py +24 -0
- spectrumizer/pt3/encode.py +159 -0
- spectrumizer/pt3/ornaments.py +51 -0
- spectrumizer/pt3/player.py +292 -0
- spectrumizer/pt3/samples.py +82 -0
- spectrumizer/pt3/writer.py +113 -0
- spectrumizer-0.1.0.dist-info/METADATA +195 -0
- spectrumizer-0.1.0.dist-info/RECORD +26 -0
- spectrumizer-0.1.0.dist-info/WHEEL +5 -0
- spectrumizer-0.1.0.dist-info/entry_points.txt +3 -0
- spectrumizer-0.1.0.dist-info/licenses/LICENSE +21 -0
- spectrumizer-0.1.0.dist-info/top_level.txt +1 -0
spectrumizer/__init__.py
ADDED
|
@@ -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"
|
spectrumizer/__main__.py
ADDED
|
@@ -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)."""
|