spectrumizer 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- spectrumizer-0.1.0/PKG-INFO +195 -0
- spectrumizer-0.1.0/README.md +166 -0
- spectrumizer-0.1.0/pyproject.toml +49 -0
- spectrumizer-0.1.0/setup.cfg +4 -0
- spectrumizer-0.1.0/spectrumizer/__init__.py +13 -0
- spectrumizer-0.1.0/spectrumizer/__main__.py +6 -0
- spectrumizer-0.1.0/spectrumizer/arrange/__init__.py +126 -0
- spectrumizer-0.1.0/spectrumizer/arrange/embellish.py +38 -0
- spectrumizer-0.1.0/spectrumizer/arrange/model.py +74 -0
- spectrumizer-0.1.0/spectrumizer/arrange/quantize.py +36 -0
- spectrumizer-0.1.0/spectrumizer/arrange/reduce.py +48 -0
- spectrumizer-0.1.0/spectrumizer/audio.py +195 -0
- spectrumizer-0.1.0/spectrumizer/cli.py +102 -0
- spectrumizer-0.1.0/spectrumizer/inputs/__init__.py +1 -0
- spectrumizer-0.1.0/spectrumizer/inputs/midi.py +81 -0
- spectrumizer-0.1.0/spectrumizer/inputs/musicxml.py +21 -0
- spectrumizer-0.1.0/spectrumizer/ir.py +51 -0
- spectrumizer-0.1.0/spectrumizer/play.py +86 -0
- spectrumizer-0.1.0/spectrumizer/pt3/__init__.py +24 -0
- spectrumizer-0.1.0/spectrumizer/pt3/encode.py +159 -0
- spectrumizer-0.1.0/spectrumizer/pt3/ornaments.py +51 -0
- spectrumizer-0.1.0/spectrumizer/pt3/player.py +292 -0
- spectrumizer-0.1.0/spectrumizer/pt3/samples.py +82 -0
- spectrumizer-0.1.0/spectrumizer/pt3/writer.py +113 -0
- spectrumizer-0.1.0/spectrumizer.egg-info/PKG-INFO +195 -0
- spectrumizer-0.1.0/spectrumizer.egg-info/SOURCES.txt +33 -0
- spectrumizer-0.1.0/spectrumizer.egg-info/dependency_links.txt +1 -0
- spectrumizer-0.1.0/spectrumizer.egg-info/entry_points.txt +3 -0
- spectrumizer-0.1.0/spectrumizer.egg-info/requires.txt +7 -0
- spectrumizer-0.1.0/spectrumizer.egg-info/top_level.txt +1 -0
- spectrumizer-0.1.0/tests/test_arrange.py +107 -0
- spectrumizer-0.1.0/tests/test_midi.py +35 -0
- spectrumizer-0.1.0/tests/test_player.py +108 -0
- spectrumizer-0.1.0/tests/test_pt3_writer.py +55 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miguel Ángel Esteve Marco
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: spectrumizer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate ZX Spectrum AY (PT3) music from MIDI and other sources.
|
|
5
|
+
Author-email: Miguel Ángel Esteve Marco <esteve@telemaco.es>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/revengator/spectrumizer
|
|
8
|
+
Project-URL: Repository, https://github.com/revengator/spectrumizer
|
|
9
|
+
Project-URL: Issues, https://github.com/revengator/spectrumizer/issues
|
|
10
|
+
Keywords: zx-spectrum,ay-3-8910,pt3,vortex-tracker,chiptune,midi
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: Conversion
|
|
19
|
+
Classifier: Topic :: Multimedia :: Sound/Audio :: MIDI
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: mido<2.0,>=1.3
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
26
|
+
Provides-Extra: demos
|
|
27
|
+
Requires-Dist: lameenc>=1.5; extra == "demos"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# spectrumizer
|
|
31
|
+
|
|
32
|
+
[](https://github.com/revengator/spectrumizer/actions/workflows/ci.yml)
|
|
33
|
+
[](LICENSE)
|
|
34
|
+
[](pyproject.toml)
|
|
35
|
+
[](https://revengator.github.io/spectrumizer/)
|
|
36
|
+
|
|
37
|
+
Generate **ZX Spectrum AY music (`.pt3`)** from public sources (MIDI now;
|
|
38
|
+
MusicXML/scores planned). The output is a standard Vortex Tracker / Sergey
|
|
39
|
+
Bulba PT3 module, so anything it produces drops straight into a Spectrum game
|
|
40
|
+
that ships a PT3 replayer.
|
|
41
|
+
|
|
42
|
+
Instead of typing every arrangement note-by-note in Python, you feed a source
|
|
43
|
+
file and spectrumizer arranges it down to the AY's 3 channels (+ noise).
|
|
44
|
+
|
|
45
|
+
**▶ Hear it in your browser:** [demo page](https://revengator.github.io/spectrumizer/)
|
|
46
|
+
· or the [Demos](#demos) section below.
|
|
47
|
+
|
|
48
|
+
> ⚠️ **Licence:** spectrumizer does **not** launder licences. The licence of the
|
|
49
|
+
> SOURCE governs the OUTPUT — a `.pt3` from a copyrighted MIDI is still
|
|
50
|
+
> copyrighted. Only bundle **public-domain or your own** music into a release.
|
|
51
|
+
> Read [`LICENSING.md`](LICENSING.md).
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install -e . # from a clone — installs the `spectrumizer` command + deps
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or, without installing the package:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
63
|
+
pip install -r requirements.txt
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
All deps are pure-Python (`mido`), so the same wheels work on Intel & Apple
|
|
67
|
+
Silicon — no native build step.
|
|
68
|
+
|
|
69
|
+
## Use
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
# faithful 3-voice reduction
|
|
73
|
+
spectrumizer song.mid -o song.pt3 # or: python -m spectrumizer song.mid -o song.pt3
|
|
74
|
+
|
|
75
|
+
# chiptune flavour: octave-doubled leads + synth drums when the source has none
|
|
76
|
+
spectrumizer song.mid -o song.pt3 --style chiptune
|
|
77
|
+
|
|
78
|
+
# tune the AY octave by ear, change grid/tempo
|
|
79
|
+
spectrumizer song.mid --transpose -12 --rows-per-beat 4 --speed 6 \
|
|
80
|
+
--name "MY THEME" --author "ME"
|
|
81
|
+
|
|
82
|
+
# dynamics: MIDI velocity drives per-note volume (on by default)
|
|
83
|
+
spectrumizer song.mid -o song.pt3 --no-dynamics # ...or flat per-channel volume
|
|
84
|
+
|
|
85
|
+
# generate and immediately hear it (renders through a software AY, then plays)
|
|
86
|
+
spectrumizer song.mid -o song.pt3 --play
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Run `spectrumizer --help` for all flags.
|
|
90
|
+
|
|
91
|
+
## How it works
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
MIDI ─(inputs/midi.py, mido)→ IR ─(arrange/)→ 3 AY channels ─(pt3/)→ .pt3
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
- **`spectrumizer/pt3/`** — the proven PT3 emitter (note encoding, channel packer,
|
|
98
|
+
samples, ornaments, file writer). The byte format is verified against the real
|
|
99
|
+
player; don't change it blindly.
|
|
100
|
+
- **`spectrumizer/arrange/`** — the hard part:
|
|
101
|
+
- `quantize` — map time to PT3's row grid (derive `speed` from tempo).
|
|
102
|
+
- `reduce` — peel the source polyphony into ≤3 monophonic lines (lead / bass /
|
|
103
|
+
harmony) via a greedy high/low "skyline".
|
|
104
|
+
- `embellish` — chiptune passes (octave leads, synth drums; chord arps planned).
|
|
105
|
+
- dynamics — MIDI velocity → per-note AY volume, normalised so the piece's
|
|
106
|
+
loudest note hits each channel's ceiling (on by default; `--no-dynamics`).
|
|
107
|
+
- Channel allocation: **A = lead, B = bass, C =** real drums if present, else
|
|
108
|
+
synth drums (chiptune), else harmony (faithful).
|
|
109
|
+
- **`spectrumizer/ir.py`** — the source-agnostic note model both inputs target.
|
|
110
|
+
|
|
111
|
+
### PT3 invariants baked in (from the player source)
|
|
112
|
+
|
|
113
|
+
The player ends a pattern when **channel A** hits its `0x00` terminator and then
|
|
114
|
+
resets all three channels — so every channel of a pattern encodes **exactly**
|
|
115
|
+
`ROWS_PER_PATTERN` (64) rows, and row 0 is never an empty rest (the packer drops
|
|
116
|
+
leading rests). `arrange/model.py` enforces both.
|
|
117
|
+
|
|
118
|
+
## Listen to it (no Spectrum needed)
|
|
119
|
+
|
|
120
|
+
spectrumizer ships its own playback path: a small software **AY-3-8910** that
|
|
121
|
+
renders a `.pt3` to a stereo `.wav` (classic ABC panning — A left, B centre,
|
|
122
|
+
C right) and plays it through your system audio player (`afplay` on macOS;
|
|
123
|
+
`ffplay` / `aplay` / `paplay` / `sox` elsewhere).
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
spectrumizer-play song.pt3 # render song.wav and play it
|
|
127
|
+
spectrumizer-play song.pt3 --no-play # just write the .wav
|
|
128
|
+
spectrumizer-play song.pt3 --seconds 30 # cap length, looping the song's tail
|
|
129
|
+
spectrumizer-play song.pt3 --rate 22050 # faster render (lower fidelity)
|
|
130
|
+
spectrumizer-play song.pt3 --tuning equal # equal-tempered instead of the PT3 table
|
|
131
|
+
spectrumizer-play song.pt3 --stereo mono # mono (default abc = A-left/B-centre/C-right)
|
|
132
|
+
spectrumizer-play song.pt3 --noise-period 5 # force a noise period (default: the module's real one)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The synth (`spectrumizer/audio.py`) plus the PT3 interpreter
|
|
136
|
+
(`spectrumizer/pt3/player.py`, the inverse of the encoder) only implement the
|
|
137
|
+
subset of PT3 this tool emits — notes, OFF, sample/ornament/volume, NtSkip. Pitch
|
|
138
|
+
uses the **exact PT3 tone table** (the table-1 periods from the real Bulba player,
|
|
139
|
+
so notes land where the chip puts them; pass `--tuning equal` for the old
|
|
140
|
+
equal-tempered approximation). Treat it as a faithful **audition**, not a
|
|
141
|
+
cycle-exact emulation. For the real thing, drop the `.pt3` into the PT3 slot of a
|
|
142
|
+
128K Spectrum build running a Bulba/Vortex replayer, rebuild, and run it in an
|
|
143
|
+
emulator (e.g. ZEsarUX).
|
|
144
|
+
|
|
145
|
+
## Demos
|
|
146
|
+
|
|
147
|
+
Hear every mode in your browser on the **[demo page](https://revengator.github.io/spectrumizer/)**
|
|
148
|
+
(GitHub Pages, nothing to install) — or click a clip to play it in GitHub's file
|
|
149
|
+
viewer. All are `examples/ode-to-joy.mid` rendered through the built-in software
|
|
150
|
+
AY; regenerate with `pip install -e ".[demos]" && python examples/make_demos.py`.
|
|
151
|
+
|
|
152
|
+
| Demo | What it shows |
|
|
153
|
+
|---|---|
|
|
154
|
+
| ▶ [Faithful](docs/audio/faithful.mp3) | 3-voice reduction |
|
|
155
|
+
| ▶ [Chiptune](docs/audio/chiptune.mp3) | octave lead + synth drums |
|
|
156
|
+
| ▶ [No dynamics](docs/audio/chiptune-flat.mp3) | flat volume — vs the velocity dynamics |
|
|
157
|
+
| ▶ [Equal-tempered](docs/audio/chiptune-equal.mp3) | vs the exact PT3 tone table |
|
|
158
|
+
| ▶ [Mono](docs/audio/chiptune-mono.mp3) | vs the default ABC stereo |
|
|
159
|
+
|
|
160
|
+
## Tests
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
pip install -e ".[dev]" # installs pytest
|
|
164
|
+
pytest -q
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Status
|
|
168
|
+
|
|
169
|
+
- **Generate:** MIDI → PT3, faithful + chiptune, with velocity-driven dynamics.
|
|
170
|
+
- **Audition:** built-in software-AY playback to a stereo WAV — exact PT3 tone
|
|
171
|
+
table, real per-frame noise period, ABC panning (`spectrumizer-play` / `--play`).
|
|
172
|
+
- **Planned:** MusicXML (music21) input, chord arpeggios, AY hardware envelopes
|
|
173
|
+
(buzzer bass), general PT3 playback (envelope/slides), raw-AY/`.vtx` export.
|
|
174
|
+
|
|
175
|
+
## Origin
|
|
176
|
+
|
|
177
|
+
spectrumizer grew out of hand-written, per-track PT3 composer scripts for a ZX
|
|
178
|
+
Spectrum game, generalising them into a single reusable arranger. It is now a
|
|
179
|
+
standalone, game-agnostic tool.
|
|
180
|
+
|
|
181
|
+
## Credits
|
|
182
|
+
|
|
183
|
+
- **Sergey Bulba** — the PT3 module format and the Vortex Tracker / PT3 replayer
|
|
184
|
+
this tool targets, including the NoteTableCreator tone-table data the audition
|
|
185
|
+
synth uses for exact Spectrum pitches.
|
|
186
|
+
- **Ivan Roshin** — NoteTableCreator, the source of those packed AY tone tables.
|
|
187
|
+
|
|
188
|
+
These credits acknowledge the format and reference data; spectrumizer's encoder,
|
|
189
|
+
decoder and synth are independent implementations (see [`LICENSE`](LICENSE)).
|
|
190
|
+
|
|
191
|
+
## Licence
|
|
192
|
+
|
|
193
|
+
[MIT](LICENSE) © Miguel Ángel Esteve Marco. Note: the MIT licence covers
|
|
194
|
+
**spectrumizer's own code**, not the music you run through it — see
|
|
195
|
+
[`LICENSING.md`](LICENSING.md).
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# spectrumizer
|
|
2
|
+
|
|
3
|
+
[](https://github.com/revengator/spectrumizer/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](pyproject.toml)
|
|
6
|
+
[](https://revengator.github.io/spectrumizer/)
|
|
7
|
+
|
|
8
|
+
Generate **ZX Spectrum AY music (`.pt3`)** from public sources (MIDI now;
|
|
9
|
+
MusicXML/scores planned). The output is a standard Vortex Tracker / Sergey
|
|
10
|
+
Bulba PT3 module, so anything it produces drops straight into a Spectrum game
|
|
11
|
+
that ships a PT3 replayer.
|
|
12
|
+
|
|
13
|
+
Instead of typing every arrangement note-by-note in Python, you feed a source
|
|
14
|
+
file and spectrumizer arranges it down to the AY's 3 channels (+ noise).
|
|
15
|
+
|
|
16
|
+
**▶ Hear it in your browser:** [demo page](https://revengator.github.io/spectrumizer/)
|
|
17
|
+
· or the [Demos](#demos) section below.
|
|
18
|
+
|
|
19
|
+
> ⚠️ **Licence:** spectrumizer does **not** launder licences. The licence of the
|
|
20
|
+
> SOURCE governs the OUTPUT — a `.pt3` from a copyrighted MIDI is still
|
|
21
|
+
> copyrighted. Only bundle **public-domain or your own** music into a release.
|
|
22
|
+
> Read [`LICENSING.md`](LICENSING.md).
|
|
23
|
+
|
|
24
|
+
## Install
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install -e . # from a clone — installs the `spectrumizer` command + deps
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Or, without installing the package:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
python3 -m venv .venv && . .venv/bin/activate
|
|
34
|
+
pip install -r requirements.txt
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
All deps are pure-Python (`mido`), so the same wheels work on Intel & Apple
|
|
38
|
+
Silicon — no native build step.
|
|
39
|
+
|
|
40
|
+
## Use
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# faithful 3-voice reduction
|
|
44
|
+
spectrumizer song.mid -o song.pt3 # or: python -m spectrumizer song.mid -o song.pt3
|
|
45
|
+
|
|
46
|
+
# chiptune flavour: octave-doubled leads + synth drums when the source has none
|
|
47
|
+
spectrumizer song.mid -o song.pt3 --style chiptune
|
|
48
|
+
|
|
49
|
+
# tune the AY octave by ear, change grid/tempo
|
|
50
|
+
spectrumizer song.mid --transpose -12 --rows-per-beat 4 --speed 6 \
|
|
51
|
+
--name "MY THEME" --author "ME"
|
|
52
|
+
|
|
53
|
+
# dynamics: MIDI velocity drives per-note volume (on by default)
|
|
54
|
+
spectrumizer song.mid -o song.pt3 --no-dynamics # ...or flat per-channel volume
|
|
55
|
+
|
|
56
|
+
# generate and immediately hear it (renders through a software AY, then plays)
|
|
57
|
+
spectrumizer song.mid -o song.pt3 --play
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Run `spectrumizer --help` for all flags.
|
|
61
|
+
|
|
62
|
+
## How it works
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
MIDI ─(inputs/midi.py, mido)→ IR ─(arrange/)→ 3 AY channels ─(pt3/)→ .pt3
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
- **`spectrumizer/pt3/`** — the proven PT3 emitter (note encoding, channel packer,
|
|
69
|
+
samples, ornaments, file writer). The byte format is verified against the real
|
|
70
|
+
player; don't change it blindly.
|
|
71
|
+
- **`spectrumizer/arrange/`** — the hard part:
|
|
72
|
+
- `quantize` — map time to PT3's row grid (derive `speed` from tempo).
|
|
73
|
+
- `reduce` — peel the source polyphony into ≤3 monophonic lines (lead / bass /
|
|
74
|
+
harmony) via a greedy high/low "skyline".
|
|
75
|
+
- `embellish` — chiptune passes (octave leads, synth drums; chord arps planned).
|
|
76
|
+
- dynamics — MIDI velocity → per-note AY volume, normalised so the piece's
|
|
77
|
+
loudest note hits each channel's ceiling (on by default; `--no-dynamics`).
|
|
78
|
+
- Channel allocation: **A = lead, B = bass, C =** real drums if present, else
|
|
79
|
+
synth drums (chiptune), else harmony (faithful).
|
|
80
|
+
- **`spectrumizer/ir.py`** — the source-agnostic note model both inputs target.
|
|
81
|
+
|
|
82
|
+
### PT3 invariants baked in (from the player source)
|
|
83
|
+
|
|
84
|
+
The player ends a pattern when **channel A** hits its `0x00` terminator and then
|
|
85
|
+
resets all three channels — so every channel of a pattern encodes **exactly**
|
|
86
|
+
`ROWS_PER_PATTERN` (64) rows, and row 0 is never an empty rest (the packer drops
|
|
87
|
+
leading rests). `arrange/model.py` enforces both.
|
|
88
|
+
|
|
89
|
+
## Listen to it (no Spectrum needed)
|
|
90
|
+
|
|
91
|
+
spectrumizer ships its own playback path: a small software **AY-3-8910** that
|
|
92
|
+
renders a `.pt3` to a stereo `.wav` (classic ABC panning — A left, B centre,
|
|
93
|
+
C right) and plays it through your system audio player (`afplay` on macOS;
|
|
94
|
+
`ffplay` / `aplay` / `paplay` / `sox` elsewhere).
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
spectrumizer-play song.pt3 # render song.wav and play it
|
|
98
|
+
spectrumizer-play song.pt3 --no-play # just write the .wav
|
|
99
|
+
spectrumizer-play song.pt3 --seconds 30 # cap length, looping the song's tail
|
|
100
|
+
spectrumizer-play song.pt3 --rate 22050 # faster render (lower fidelity)
|
|
101
|
+
spectrumizer-play song.pt3 --tuning equal # equal-tempered instead of the PT3 table
|
|
102
|
+
spectrumizer-play song.pt3 --stereo mono # mono (default abc = A-left/B-centre/C-right)
|
|
103
|
+
spectrumizer-play song.pt3 --noise-period 5 # force a noise period (default: the module's real one)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
The synth (`spectrumizer/audio.py`) plus the PT3 interpreter
|
|
107
|
+
(`spectrumizer/pt3/player.py`, the inverse of the encoder) only implement the
|
|
108
|
+
subset of PT3 this tool emits — notes, OFF, sample/ornament/volume, NtSkip. Pitch
|
|
109
|
+
uses the **exact PT3 tone table** (the table-1 periods from the real Bulba player,
|
|
110
|
+
so notes land where the chip puts them; pass `--tuning equal` for the old
|
|
111
|
+
equal-tempered approximation). Treat it as a faithful **audition**, not a
|
|
112
|
+
cycle-exact emulation. For the real thing, drop the `.pt3` into the PT3 slot of a
|
|
113
|
+
128K Spectrum build running a Bulba/Vortex replayer, rebuild, and run it in an
|
|
114
|
+
emulator (e.g. ZEsarUX).
|
|
115
|
+
|
|
116
|
+
## Demos
|
|
117
|
+
|
|
118
|
+
Hear every mode in your browser on the **[demo page](https://revengator.github.io/spectrumizer/)**
|
|
119
|
+
(GitHub Pages, nothing to install) — or click a clip to play it in GitHub's file
|
|
120
|
+
viewer. All are `examples/ode-to-joy.mid` rendered through the built-in software
|
|
121
|
+
AY; regenerate with `pip install -e ".[demos]" && python examples/make_demos.py`.
|
|
122
|
+
|
|
123
|
+
| Demo | What it shows |
|
|
124
|
+
|---|---|
|
|
125
|
+
| ▶ [Faithful](docs/audio/faithful.mp3) | 3-voice reduction |
|
|
126
|
+
| ▶ [Chiptune](docs/audio/chiptune.mp3) | octave lead + synth drums |
|
|
127
|
+
| ▶ [No dynamics](docs/audio/chiptune-flat.mp3) | flat volume — vs the velocity dynamics |
|
|
128
|
+
| ▶ [Equal-tempered](docs/audio/chiptune-equal.mp3) | vs the exact PT3 tone table |
|
|
129
|
+
| ▶ [Mono](docs/audio/chiptune-mono.mp3) | vs the default ABC stereo |
|
|
130
|
+
|
|
131
|
+
## Tests
|
|
132
|
+
|
|
133
|
+
```bash
|
|
134
|
+
pip install -e ".[dev]" # installs pytest
|
|
135
|
+
pytest -q
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Status
|
|
139
|
+
|
|
140
|
+
- **Generate:** MIDI → PT3, faithful + chiptune, with velocity-driven dynamics.
|
|
141
|
+
- **Audition:** built-in software-AY playback to a stereo WAV — exact PT3 tone
|
|
142
|
+
table, real per-frame noise period, ABC panning (`spectrumizer-play` / `--play`).
|
|
143
|
+
- **Planned:** MusicXML (music21) input, chord arpeggios, AY hardware envelopes
|
|
144
|
+
(buzzer bass), general PT3 playback (envelope/slides), raw-AY/`.vtx` export.
|
|
145
|
+
|
|
146
|
+
## Origin
|
|
147
|
+
|
|
148
|
+
spectrumizer grew out of hand-written, per-track PT3 composer scripts for a ZX
|
|
149
|
+
Spectrum game, generalising them into a single reusable arranger. It is now a
|
|
150
|
+
standalone, game-agnostic tool.
|
|
151
|
+
|
|
152
|
+
## Credits
|
|
153
|
+
|
|
154
|
+
- **Sergey Bulba** — the PT3 module format and the Vortex Tracker / PT3 replayer
|
|
155
|
+
this tool targets, including the NoteTableCreator tone-table data the audition
|
|
156
|
+
synth uses for exact Spectrum pitches.
|
|
157
|
+
- **Ivan Roshin** — NoteTableCreator, the source of those packed AY tone tables.
|
|
158
|
+
|
|
159
|
+
These credits acknowledge the format and reference data; spectrumizer's encoder,
|
|
160
|
+
decoder and synth are independent implementations (see [`LICENSE`](LICENSE)).
|
|
161
|
+
|
|
162
|
+
## Licence
|
|
163
|
+
|
|
164
|
+
[MIT](LICENSE) © Miguel Ángel Esteve Marco. Note: the MIT licence covers
|
|
165
|
+
**spectrumizer's own code**, not the music you run through it — see
|
|
166
|
+
[`LICENSING.md`](LICENSING.md).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "spectrumizer"
|
|
7
|
+
description = "Generate ZX Spectrum AY (PT3) music from MIDI and other sources."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Miguel Ángel Esteve Marco", email = "esteve@telemaco.es" }]
|
|
12
|
+
keywords = ["zx-spectrum", "ay-3-8910", "pt3", "vortex-tracker", "chiptune", "midi"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Topic :: Multimedia :: Sound/Audio :: Conversion",
|
|
22
|
+
"Topic :: Multimedia :: Sound/Audio :: MIDI",
|
|
23
|
+
]
|
|
24
|
+
dynamic = ["version"]
|
|
25
|
+
dependencies = ["mido>=1.3,<2.0"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
dev = ["pytest>=7"]
|
|
29
|
+
demos = ["lameenc>=1.5"] # MP3 encoder for examples/make_demos.py (pip wheel, no system ffmpeg)
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
spectrumizer = "spectrumizer.cli:main"
|
|
33
|
+
spectrumizer-play = "spectrumizer.play:main"
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/revengator/spectrumizer"
|
|
37
|
+
Repository = "https://github.com/revengator/spectrumizer"
|
|
38
|
+
Issues = "https://github.com/revengator/spectrumizer/issues"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
packages = [
|
|
42
|
+
"spectrumizer",
|
|
43
|
+
"spectrumizer.arrange",
|
|
44
|
+
"spectrumizer.inputs",
|
|
45
|
+
"spectrumizer.pt3",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
[tool.setuptools.dynamic]
|
|
49
|
+
version = { attr = "spectrumizer.__version__" }
|
|
@@ -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,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
|