arkos2basic 0.1.0b6__tar.gz → 0.1.1b1__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.
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/PKG-INFO +1 -1
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/pyproject.toml +1 -1
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/arkostracker/baseimport.py +65 -8
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/cli.py +137 -0
- arkos2basic-0.1.1b1/src/arkos2basic/cvbasic.py +1307 -0
- arkos2basic-0.1.0b6/src/arkos2basic/cvbasic.py +0 -463
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/README.md +0 -0
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/__init__.py +0 -0
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/arkostracker/__init__.py +0 -0
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/arkostracker/cell.py +0 -0
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/arkostracker/song.py +0 -0
- {arkos2basic-0.1.0b6 → arkos2basic-0.1.1b1}/src/arkos2basic/arkostracker/txtimport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arkos2basic
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1b1
|
|
4
4
|
Summary: Converts Arkos Tracker 3 text exports to CVBasic MUSIC blocks. Handles note transposition, variable note duration, percussion detection and intro/loop splitting. Targets ColecoVision, MSX and Sega SG-1000.
|
|
5
5
|
Author: Francesco Maida
|
|
6
6
|
Author-email: francesco.maida@gmail.com
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "arkos2basic"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.1b1"
|
|
4
4
|
description = "Converts Arkos Tracker 3 text exports to CVBasic MUSIC blocks. Handles note transposition, variable note duration, percussion detection and intro/loop splitting. Targets ColecoVision, MSX and Sega SG-1000."
|
|
5
5
|
authors = [
|
|
6
6
|
{name = "Francesco Maida",email = "francesco.maida@gmail.com"}
|
|
@@ -5,7 +5,7 @@ from abc import ABC, abstractmethod
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
7
|
from arkos2basic.arkostracker.song import Song
|
|
8
|
-
from arkos2basic.cvbasic import convert
|
|
8
|
+
from arkos2basic.cvbasic import convert, convert_playlist
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
logger = logging.getLogger(__name__)
|
|
@@ -72,6 +72,11 @@ class BaseImport(ABC):
|
|
|
72
72
|
drum_length: int,
|
|
73
73
|
exclude_channels: set[int] | None = None,
|
|
74
74
|
map_instruments: bool = True,
|
|
75
|
+
force_suffixes: dict[int, str] | None = None,
|
|
76
|
+
playlist: bool = False,
|
|
77
|
+
min_block: int = 32,
|
|
78
|
+
fuse: set[int] | None = None,
|
|
79
|
+
pack: bool = False,
|
|
75
80
|
) -> None:
|
|
76
81
|
"""Convert the song to CVBasic and write the output file(s).
|
|
77
82
|
|
|
@@ -80,6 +85,10 @@ class BaseImport(ABC):
|
|
|
80
85
|
Otherwise a single file is written. Status messages are emitted
|
|
81
86
|
via the module logger at INFO level.
|
|
82
87
|
|
|
88
|
+
Playlist mode replaces the split: the song is cut into reusable
|
|
89
|
+
blocks and a single file carries the blocks, the playlist and the
|
|
90
|
+
BASIC glue that chains them, looping by rewinding the playlist.
|
|
91
|
+
|
|
83
92
|
Args:
|
|
84
93
|
output_file: path for the main output .bas file.
|
|
85
94
|
label: label assigned to the music block.
|
|
@@ -91,9 +100,25 @@ class BaseImport(ABC):
|
|
|
91
100
|
exclude_channels: 1-based source channel indices to skip.
|
|
92
101
|
map_instruments: if True, Arkos instruments 1-3 are mapped
|
|
93
102
|
to the CVBasic suffixes W/X/Y.
|
|
103
|
+
force_suffixes: map of 1-based source channel index ->
|
|
104
|
+
CVBasic instrument (W/X/Y/Z) forced on that channel,
|
|
105
|
+
overriding the instrument mapping.
|
|
106
|
+
playlist: if True, deduplicate repeated stretches into
|
|
107
|
+
reusable blocks driven by a playlist.
|
|
108
|
+
min_block: shortest repeated stretch worth reusing, in steps.
|
|
109
|
+
fuse: playlist indices after which the junction is removed.
|
|
110
|
+
pack: if True, emit raw three-byte rows instead of MUSIC
|
|
111
|
+
lines; ignored (with a message) when the song has
|
|
112
|
+
percussion, which needs the fourth column.
|
|
94
113
|
"""
|
|
95
114
|
song = self._song
|
|
96
|
-
|
|
115
|
+
|
|
116
|
+
if pack and song.drum_instruments:
|
|
117
|
+
logger.info(
|
|
118
|
+
"Packing disabled: it drops the percussion column, and this "
|
|
119
|
+
"song uses percussion."
|
|
120
|
+
)
|
|
121
|
+
pack = False
|
|
97
122
|
|
|
98
123
|
conv_args: dict = dict(
|
|
99
124
|
data_byte=data_byte,
|
|
@@ -103,8 +128,27 @@ class BaseImport(ABC):
|
|
|
103
128
|
exclude_channels=exclude_channels,
|
|
104
129
|
transpose=self._transpose,
|
|
105
130
|
map_instruments=map_instruments,
|
|
131
|
+
force_suffixes=force_suffixes,
|
|
132
|
+
pack=pack,
|
|
106
133
|
)
|
|
107
134
|
|
|
135
|
+
if playlist:
|
|
136
|
+
result = convert_playlist(
|
|
137
|
+
song, label=label, stop=stop,
|
|
138
|
+
min_block=min_block, fuse=fuse,
|
|
139
|
+
include_name=output_file.name, **conv_args,
|
|
140
|
+
)
|
|
141
|
+
if result is not None:
|
|
142
|
+
source, speeds = result
|
|
143
|
+
output_file.write_text(source, encoding="utf-8")
|
|
144
|
+
logger.info("Output written to: %s", output_file)
|
|
145
|
+
self._log_song_info(speeds)
|
|
146
|
+
return
|
|
147
|
+
# convert_playlist explained why; fall through to the
|
|
148
|
+
# classic single-block output.
|
|
149
|
+
|
|
150
|
+
do_split = song.loop_start_position > 0 and not stop
|
|
151
|
+
|
|
108
152
|
if do_split:
|
|
109
153
|
song_end = song.playback_end
|
|
110
154
|
source_intro, speeds_intro = convert(
|
|
@@ -139,20 +183,33 @@ class BaseImport(ABC):
|
|
|
139
183
|
)
|
|
140
184
|
output_file.write_text(source, encoding="utf-8")
|
|
141
185
|
|
|
142
|
-
|
|
186
|
+
if pack:
|
|
187
|
+
# Every step is a DATA BYTE row, minus the header and
|
|
188
|
+
# the terminator row.
|
|
189
|
+
steps = source.count("\tDATA BYTE $") - 2
|
|
190
|
+
else:
|
|
191
|
+
# Every step is a MUSIC row, minus the final STOP/REPEAT.
|
|
192
|
+
steps = source.count("\tMUSIC ") - 1
|
|
143
193
|
logger.info("Converted: %d patterns.", len(song.positions))
|
|
144
|
-
logger.info(
|
|
145
|
-
"DATA BYTE: %d -> MUSIC rows: %d", data_byte, music_lines
|
|
146
|
-
)
|
|
194
|
+
logger.info("DATA BYTE: %d -> MUSIC rows: %d", data_byte, steps)
|
|
147
195
|
logger.info("Output written to: %s", output_file)
|
|
148
196
|
|
|
197
|
+
self._log_song_info(speeds)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _log_song_info(self, speeds: set[int]) -> None:
|
|
201
|
+
"""Log the tracker speeds and the percussion instruments found.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
speeds: the tracker speeds encountered during conversion.
|
|
205
|
+
"""
|
|
149
206
|
logger.info(
|
|
150
207
|
"Speeds found (tracker frames/row): %s", sorted(speeds)
|
|
151
208
|
)
|
|
152
|
-
if
|
|
209
|
+
if self._song.drum_instruments:
|
|
153
210
|
logger.info(
|
|
154
211
|
"Percussion instruments detected: %s",
|
|
155
|
-
dict(
|
|
212
|
+
dict(self._song.drum_instruments),
|
|
156
213
|
)
|
|
157
214
|
else:
|
|
158
215
|
logger.info("No percussion instruments detected.")
|
|
@@ -12,10 +12,52 @@ from typing import Annotated
|
|
|
12
12
|
import typer
|
|
13
13
|
|
|
14
14
|
from arkos2basic.arkostracker.txtimport import TXTImport
|
|
15
|
+
from arkos2basic.cvbasic import MAX_DATA_BYTE
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
app = typer.Typer(help="Convert an Arkos Tracker (.txt) file to CVBasic music.")
|
|
18
19
|
|
|
20
|
+
# Names accepted by --channel1/2/3, plus the CVBasic letters themselves.
|
|
21
|
+
CHANNEL_INSTRUMENTS: dict[str, str] = {
|
|
22
|
+
"piano": "W",
|
|
23
|
+
"clarinet": "X",
|
|
24
|
+
"flute": "Y",
|
|
25
|
+
"bass": "Z",
|
|
26
|
+
"w": "W",
|
|
27
|
+
"x": "X",
|
|
28
|
+
"y": "Y",
|
|
29
|
+
"z": "Z",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def parse_channel_instrument(value: str, option: str) -> str | None:
|
|
34
|
+
"""Turn a --channelN value into the CVBasic instrument suffix.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
value: the raw option value, empty when the option was omitted.
|
|
38
|
+
option: the option name, used in the error message.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The suffix (W/X/Y/Z), or None if the option was not given.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
typer.Exit: if the value names no known instrument.
|
|
45
|
+
"""
|
|
46
|
+
name = value.strip().lower()
|
|
47
|
+
if not name:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
suffix = CHANNEL_INSTRUMENTS.get(name)
|
|
51
|
+
if suffix is None:
|
|
52
|
+
typer.echo(
|
|
53
|
+
f"Error: {option} only accepts piano, clarinet, flute or bass "
|
|
54
|
+
f"(got: '{value}')",
|
|
55
|
+
err=True,
|
|
56
|
+
)
|
|
57
|
+
raise typer.Exit(1)
|
|
58
|
+
|
|
59
|
+
return suffix
|
|
60
|
+
|
|
19
61
|
|
|
20
62
|
@app.command()
|
|
21
63
|
def main(
|
|
@@ -98,12 +140,90 @@ def main(
|
|
|
98
140
|
"all melodic notes use the default piano.",
|
|
99
141
|
),
|
|
100
142
|
] = False,
|
|
143
|
+
channel1: Annotated[
|
|
144
|
+
str,
|
|
145
|
+
typer.Option(
|
|
146
|
+
"--channel1",
|
|
147
|
+
help="Force one CVBasic instrument on source channel 1: "
|
|
148
|
+
"piano, clarinet, flute or bass. Overrides the Arkos "
|
|
149
|
+
"instrument mapping; the octave the player adds to "
|
|
150
|
+
"clarinet and bass is compensated automatically.",
|
|
151
|
+
),
|
|
152
|
+
] = "",
|
|
153
|
+
channel2: Annotated[
|
|
154
|
+
str,
|
|
155
|
+
typer.Option("--channel2", help="Same as --channel1, for source channel 2."),
|
|
156
|
+
] = "",
|
|
157
|
+
channel3: Annotated[
|
|
158
|
+
str,
|
|
159
|
+
typer.Option("--channel3", help="Same as --channel1, for source channel 3."),
|
|
160
|
+
] = "",
|
|
161
|
+
playlist: Annotated[
|
|
162
|
+
bool,
|
|
163
|
+
typer.Option(
|
|
164
|
+
"--playlist",
|
|
165
|
+
help="Reuse repeated stretches: emit one block per unique "
|
|
166
|
+
"stretch plus a playlist and the BASIC glue that chains "
|
|
167
|
+
"them. Replaces the intro/loop split.",
|
|
168
|
+
),
|
|
169
|
+
] = False,
|
|
170
|
+
min_block: Annotated[
|
|
171
|
+
int,
|
|
172
|
+
typer.Option(
|
|
173
|
+
help="Shortest repeated stretch worth reusing, in MUSIC steps "
|
|
174
|
+
"(--playlist only). Higher = fewer junctions.",
|
|
175
|
+
),
|
|
176
|
+
] = 32,
|
|
177
|
+
fuse: Annotated[
|
|
178
|
+
str,
|
|
179
|
+
typer.Option(
|
|
180
|
+
"--fuse",
|
|
181
|
+
help="Playlist entries after which the junction is removed, "
|
|
182
|
+
"comma-separated (e.g. '2,5'). Use when a junction is "
|
|
183
|
+
"audible; the conversion log numbers the entries.",
|
|
184
|
+
),
|
|
185
|
+
] = "",
|
|
186
|
+
pack: Annotated[
|
|
187
|
+
bool,
|
|
188
|
+
typer.Option(
|
|
189
|
+
"--pack3",
|
|
190
|
+
help="Emit raw 3-byte rows instead of MUSIC lines, saving 25% "
|
|
191
|
+
"of the music ROM. Songs without percussion only.",
|
|
192
|
+
),
|
|
193
|
+
] = False,
|
|
101
194
|
) -> None:
|
|
102
195
|
"""Command-line entry point."""
|
|
103
196
|
if data_byte < 1:
|
|
104
197
|
typer.echo("Error: --data-byte must be at least 1", err=True)
|
|
105
198
|
raise typer.Exit(1)
|
|
106
199
|
|
|
200
|
+
if data_byte > MAX_DATA_BYTE:
|
|
201
|
+
typer.echo(
|
|
202
|
+
f"Error: --data-byte must be at most {MAX_DATA_BYTE} "
|
|
203
|
+
f"(the player keeps it in 6 bits)",
|
|
204
|
+
err=True,
|
|
205
|
+
)
|
|
206
|
+
raise typer.Exit(1)
|
|
207
|
+
|
|
208
|
+
if min_block < 1:
|
|
209
|
+
typer.echo("Error: --min-block must be at least 1", err=True)
|
|
210
|
+
raise typer.Exit(1)
|
|
211
|
+
|
|
212
|
+
fuse_set: set[int] = set()
|
|
213
|
+
for part in fuse.split(","):
|
|
214
|
+
part = part.strip()
|
|
215
|
+
if not part:
|
|
216
|
+
continue
|
|
217
|
+
try:
|
|
218
|
+
fuse_set.add(int(part))
|
|
219
|
+
except ValueError:
|
|
220
|
+
typer.echo(
|
|
221
|
+
f"Error: --fuse only accepts playlist entry numbers "
|
|
222
|
+
f"(got: '{part}')",
|
|
223
|
+
err=True,
|
|
224
|
+
)
|
|
225
|
+
raise typer.Exit(1)
|
|
226
|
+
|
|
107
227
|
excluded: set[int] = set()
|
|
108
228
|
for part in exclude_channels.split(","):
|
|
109
229
|
part = part.strip()
|
|
@@ -127,6 +247,18 @@ def main(
|
|
|
127
247
|
raise typer.Exit(1)
|
|
128
248
|
excluded.add(val)
|
|
129
249
|
|
|
250
|
+
forced: dict[int, str] = {}
|
|
251
|
+
for index, value in ((1, channel1), (2, channel2), (3, channel3)):
|
|
252
|
+
suffix = parse_channel_instrument(value, f"--channel{index}")
|
|
253
|
+
if suffix is not None:
|
|
254
|
+
forced[index] = suffix
|
|
255
|
+
|
|
256
|
+
for index in sorted(set(forced) & excluded):
|
|
257
|
+
typer.echo(
|
|
258
|
+
f"Warning: --channel{index} has no effect, "
|
|
259
|
+
f"channel {index} is excluded."
|
|
260
|
+
)
|
|
261
|
+
|
|
130
262
|
importer = TXTImport(input_file=input_file)
|
|
131
263
|
|
|
132
264
|
# --octaves N contributes (1 + N) * 12 semitones (base 1 octave + delta).
|
|
@@ -155,6 +287,11 @@ def main(
|
|
|
155
287
|
drum_length=2 + drum_length,
|
|
156
288
|
exclude_channels=exclude_set,
|
|
157
289
|
map_instruments=not no_instruments,
|
|
290
|
+
force_suffixes=forced or None,
|
|
291
|
+
playlist=playlist,
|
|
292
|
+
min_block=min_block,
|
|
293
|
+
fuse=fuse_set,
|
|
294
|
+
pack=pack,
|
|
158
295
|
)
|
|
159
296
|
finally:
|
|
160
297
|
pkg_logger.removeHandler(log_handler)
|