arkos2basic 0.1.0b5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkos2basic
3
- Version: 0.1.0b5
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.0b5"
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"}
@@ -17,6 +17,12 @@ packages = [{include = "arkos2basic", from = "src"}]
17
17
  [tool.poetry.scripts]
18
18
  arkos2basic = "arkos2basic.cli:app"
19
19
 
20
+ [tool.poetry.group.dev.dependencies]
21
+ pytest = "^8.3"
22
+
23
+ [tool.pytest.ini_options]
24
+ testpaths = ["tests"]
25
+
20
26
  [build-system]
21
27
  requires = ["poetry-core>=2.0.0,<3.0.0"]
22
28
  build-backend = "poetry.core.masonry.api"
@@ -1,16 +1,17 @@
1
1
  """Base class for Arkos Tracker file importers."""
2
2
 
3
3
  import logging
4
+ from abc import ABC, abstractmethod
4
5
  from pathlib import Path
5
6
 
6
7
  from arkos2basic.arkostracker.song import Song
7
- from arkos2basic.cvbasic import convert
8
+ from arkos2basic.cvbasic import convert, convert_playlist
8
9
 
9
10
 
10
11
  logger = logging.getLogger(__name__)
11
12
 
12
13
 
13
- class BaseImport:
14
+ class BaseImport(ABC):
14
15
  """Base importer for Arkos Tracker source files.
15
16
 
16
17
  Subclasses implement parse() to read a specific file format and return
@@ -38,6 +39,7 @@ class BaseImport:
38
39
  self._transpose: int = 0
39
40
 
40
41
 
42
+ @abstractmethod
41
43
  def parse(self) -> Song:
42
44
  """Read self.input_file and return the parsed Song.
43
45
 
@@ -69,6 +71,12 @@ class BaseImport:
69
71
  stop: bool,
70
72
  drum_length: int,
71
73
  exclude_channels: set[int] | None = None,
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,
72
80
  ) -> None:
73
81
  """Convert the song to CVBasic and write the output file(s).
74
82
 
@@ -77,6 +85,10 @@ class BaseImport:
77
85
  Otherwise a single file is written. Status messages are emitted
78
86
  via the module logger at INFO level.
79
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
+
80
92
  Args:
81
93
  output_file: path for the main output .bas file.
82
94
  label: label assigned to the music block.
@@ -86,9 +98,27 @@ class BaseImport:
86
98
  stop: if True, always end with MUSIC STOP (no split).
87
99
  drum_length: consecutive steps per percussion hit.
88
100
  exclude_channels: 1-based source channel indices to skip.
101
+ map_instruments: if True, Arkos instruments 1-3 are mapped
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.
89
113
  """
90
114
  song = self._song
91
- do_split = song.loop_start_position > 0 and not stop
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
92
122
 
93
123
  conv_args: dict = dict(
94
124
  data_byte=data_byte,
@@ -97,13 +127,30 @@ class BaseImport:
97
127
  drum_length=drum_length,
98
128
  exclude_channels=exclude_channels,
99
129
  transpose=self._transpose,
130
+ map_instruments=map_instruments,
131
+ force_suffixes=force_suffixes,
132
+ pack=pack,
100
133
  )
101
134
 
102
- if do_split:
103
- song_end = (
104
- song.end_position + 1 if song.end_position >= 0
105
- else len(song.positions)
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,
106
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
+
152
+ if do_split:
153
+ song_end = song.playback_end
107
154
  source_intro, speeds_intro = convert(
108
155
  song, label=label, stop=True,
109
156
  pos_start=0, pos_end=song.loop_start_position,
@@ -136,20 +183,33 @@ class BaseImport:
136
183
  )
137
184
  output_file.write_text(source, encoding="utf-8")
138
185
 
139
- music_lines = source.count("\tMUSIC ")
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
140
193
  logger.info("Converted: %d patterns.", len(song.positions))
141
- logger.info(
142
- "DATA BYTE: %d -> MUSIC rows: %d", data_byte, music_lines
143
- )
194
+ logger.info("DATA BYTE: %d -> MUSIC rows: %d", data_byte, steps)
144
195
  logger.info("Output written to: %s", output_file)
145
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
+ """
146
206
  logger.info(
147
207
  "Speeds found (tracker frames/row): %s", sorted(speeds)
148
208
  )
149
- if song.drum_instruments:
209
+ if self._song.drum_instruments:
150
210
  logger.info(
151
211
  "Percussion instruments detected: %s",
152
- dict(song.drum_instruments),
212
+ dict(self._song.drum_instruments),
153
213
  )
154
214
  else:
155
215
  logger.info("No percussion instruments detected.")
@@ -33,3 +33,16 @@ class Song:
33
33
  drum_instruments: dict[int, str] = field(default_factory=dict)
34
34
  loop_start_position: int = 0
35
35
  end_position: int = -1
36
+
37
+
38
+ @property
39
+ def playback_end(self) -> int:
40
+ """Exclusive index of the last position to play.
41
+
42
+ Returns:
43
+ end_position + 1 if set, otherwise the full positions length.
44
+ """
45
+ if self.end_position >= 0:
46
+ return self.end_position + 1
47
+
48
+ return len(self.positions)
@@ -170,16 +170,6 @@ class TXTImport(BaseImport):
170
170
  return song
171
171
 
172
172
 
173
- def transpose(self, notes: int) -> None:
174
- """Set the semitone shift for all melodic notes on export.
175
-
176
- Args:
177
- notes: total semitones to add (positive = up, negative = down;
178
- 12 = one octave up).
179
- """
180
- super().transpose(notes)
181
-
182
-
183
173
  def _noise_to_drum_type(self, avg_noise: float) -> str:
184
174
  """Map the average noise period of an instrument to a CVBasic drum type.
185
175
 
@@ -0,0 +1,301 @@
1
+ """Arkos Tracker text format (V1.0) to CVBasic music converter — CLI entry point.
2
+
3
+ Receives command-line arguments, orchestrates the importer and the CVBasic
4
+ exporter, and writes status messages to stdout at the end of execution.
5
+ """
6
+
7
+ import logging
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+
14
+ from arkos2basic.arkostracker.txtimport import TXTImport
15
+ from arkos2basic.cvbasic import MAX_DATA_BYTE
16
+
17
+
18
+ app = typer.Typer(help="Convert an Arkos Tracker (.txt) file to CVBasic music.")
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
+
61
+
62
+ @app.command()
63
+ def main(
64
+ input_file: Annotated[
65
+ Path,
66
+ typer.Argument(
67
+ help="Input Arkos Tracker file",
68
+ exists=True,
69
+ dir_okay=False,
70
+ readable=True,
71
+ ),
72
+ ],
73
+ output_file: Annotated[
74
+ Path,
75
+ typer.Argument(help="Output .bas file"),
76
+ ],
77
+ octaves: Annotated[
78
+ int,
79
+ typer.Option(
80
+ help="Octave delta relative to base +1 "
81
+ "(e.g. --octaves 1 → +2 octaves, --octaves -1 → 0)"
82
+ ),
83
+ ] = 0,
84
+ data_byte: Annotated[
85
+ int,
86
+ typer.Option(
87
+ help="Frames per MUSIC step: lower = more faithful but more rows "
88
+ "(1 = exact timing)"
89
+ ),
90
+ ] = 2,
91
+ length_scale: Annotated[
92
+ float,
93
+ typer.Option(
94
+ "--length",
95
+ help="Delta from base 3.0 for note sounding duration "
96
+ "(e.g. --length 1 → 4.0, --length -1 → 2.0)",
97
+ ),
98
+ ] = 0.0,
99
+ invert: Annotated[
100
+ bool,
101
+ typer.Option(
102
+ help="Invert the scale: high forceInstrumentSpeed = shorter note"
103
+ ),
104
+ ] = False,
105
+ label: Annotated[
106
+ str,
107
+ typer.Option(help="Label for the music block in the output source"),
108
+ ] = "musica",
109
+ stop: Annotated[
110
+ bool,
111
+ typer.Option("--stop", help="End with MUSIC STOP instead of MUSIC REPEAT"),
112
+ ] = False,
113
+ drum_length: Annotated[
114
+ int,
115
+ typer.Option(
116
+ help="Delta from base 2 steps per percussion hit "
117
+ "(e.g. --drum-length 1 → 3, --drum-length -1 → 1)",
118
+ ),
119
+ ] = 0,
120
+ exclude_channels: Annotated[
121
+ str,
122
+ typer.Option(
123
+ "--exclude-channels",
124
+ help="Source channels to exclude, comma-separated "
125
+ "(1-3, e.g. '2' or '2,3'). Remaining channels pack left.",
126
+ ),
127
+ ] = "",
128
+ transpose: Annotated[
129
+ int,
130
+ typer.Option(
131
+ help="Chromatic semitones to add to all notes "
132
+ "(positive = up, negative = down; 12 = +1 octave).",
133
+ ),
134
+ ] = 0,
135
+ no_instruments: Annotated[
136
+ bool,
137
+ typer.Option(
138
+ "--no-instruments",
139
+ help="Do not map Arkos instruments 1-3 to CVBasic W/X/Y; "
140
+ "all melodic notes use the default piano.",
141
+ ),
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,
194
+ ) -> None:
195
+ """Command-line entry point."""
196
+ if data_byte < 1:
197
+ typer.echo("Error: --data-byte must be at least 1", err=True)
198
+ raise typer.Exit(1)
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
+
227
+ excluded: set[int] = set()
228
+ for part in exclude_channels.split(","):
229
+ part = part.strip()
230
+ if not part:
231
+ continue
232
+ try:
233
+ val = int(part)
234
+ except ValueError:
235
+ typer.echo(
236
+ f"Error: --exclude-channels only accepts values 1, 2, 3 "
237
+ f"(got: '{part}')",
238
+ err=True,
239
+ )
240
+ raise typer.Exit(1)
241
+ if val not in (1, 2, 3):
242
+ typer.echo(
243
+ f"Error: --exclude-channels only accepts values 1, 2, 3 "
244
+ f"(got: {val})",
245
+ err=True,
246
+ )
247
+ raise typer.Exit(1)
248
+ excluded.add(val)
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
+
262
+ importer = TXTImport(input_file=input_file)
263
+
264
+ # --octaves N contributes (1 + N) * 12 semitones (base 1 octave + delta).
265
+ # --transpose M adds M fine semitones on top.
266
+ effective_transpose = (1 + octaves) * 12 + transpose
267
+ importer.transpose(effective_transpose)
268
+
269
+ log_handler = logging.StreamHandler(sys.stdout)
270
+ log_handler.setFormatter(logging.Formatter("%(message)s"))
271
+ pkg_logger = logging.getLogger("arkos2basic")
272
+ pkg_logger.addHandler(log_handler)
273
+ pkg_logger.setLevel(logging.INFO)
274
+
275
+ exclude_set: set[int] | None = None
276
+ if excluded:
277
+ exclude_set = excluded
278
+
279
+ try:
280
+ importer.export(
281
+ output_file=output_file,
282
+ label=label,
283
+ data_byte=data_byte,
284
+ length_scale=3.0 + length_scale,
285
+ invert_speed=invert,
286
+ stop=stop,
287
+ drum_length=2 + drum_length,
288
+ exclude_channels=exclude_set,
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,
295
+ )
296
+ finally:
297
+ pkg_logger.removeHandler(log_handler)
298
+
299
+
300
+ if __name__ == "__main__":
301
+ app()