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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkos2basic
3
- Version: 0.1.0b6
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.0b6"
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
- 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
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
- 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
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 song.drum_instruments:
209
+ if self._song.drum_instruments:
153
210
  logger.info(
154
211
  "Percussion instruments detected: %s",
155
- dict(song.drum_instruments),
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)