arkos2basic 0.1.0b4__tar.gz → 0.1.0b6__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,11 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkos2basic
3
- Version: 0.1.0b4
3
+ Version: 0.1.0b6
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
7
- Requires-Python: >=3.12,<4
7
+ Requires-Python: >=3.10,<4
8
8
  Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
9
11
  Classifier: Programming Language :: Python :: 3.12
10
12
  Classifier: Programming Language :: Python :: 3.13
11
13
  Classifier: Programming Language :: Python :: 3.14
@@ -89,5 +91,5 @@ INCLUDE mymusic_loop.bas
89
91
 
90
92
  ## Requirements
91
93
 
92
- Python 3.12 or better.
94
+ Python 3.10 or better.
93
95
 
@@ -75,4 +75,4 @@ INCLUDE mymusic_loop.bas
75
75
 
76
76
  ## Requirements
77
77
 
78
- Python 3.12 or better.
78
+ Python 3.10 or better.
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "arkos2basic"
3
- version = "0.1.0b4"
3
+ version = "0.1.0b6"
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"}
7
7
  ]
8
8
  readme = "README.md"
9
- requires-python = ">=3.12,<4"
9
+ requires-python = ">=3.10,<4"
10
10
  dependencies = [
11
11
  "typer (>=0.26.7,<0.27.0)"
12
12
  ]
@@ -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,6 +1,7 @@
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
@@ -10,7 +11,7 @@ from arkos2basic.cvbasic import convert
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,7 @@ class BaseImport:
69
71
  stop: bool,
70
72
  drum_length: int,
71
73
  exclude_channels: set[int] | None = None,
74
+ map_instruments: bool = True,
72
75
  ) -> None:
73
76
  """Convert the song to CVBasic and write the output file(s).
74
77
 
@@ -86,6 +89,8 @@ class BaseImport:
86
89
  stop: if True, always end with MUSIC STOP (no split).
87
90
  drum_length: consecutive steps per percussion hit.
88
91
  exclude_channels: 1-based source channel indices to skip.
92
+ map_instruments: if True, Arkos instruments 1-3 are mapped
93
+ to the CVBasic suffixes W/X/Y.
89
94
  """
90
95
  song = self._song
91
96
  do_split = song.loop_start_position > 0 and not stop
@@ -97,13 +102,11 @@ class BaseImport:
97
102
  drum_length=drum_length,
98
103
  exclude_channels=exclude_channels,
99
104
  transpose=self._transpose,
105
+ map_instruments=map_instruments,
100
106
  )
101
107
 
102
108
  if do_split:
103
- song_end = (
104
- song.end_position + 1 if song.end_position >= 0
105
- else len(song.positions)
106
- )
109
+ song_end = song.playback_end
107
110
  source_intro, speeds_intro = convert(
108
111
  song, label=label, stop=True,
109
112
  pos_start=0, pos_end=song.loop_start_position,
@@ -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
 
@@ -6,7 +6,6 @@ exporter, and writes status messages to stdout at the end of execution.
6
6
 
7
7
  import logging
8
8
  import sys
9
- from io import StringIO
10
9
  from pathlib import Path
11
10
  from typing import Annotated
12
11
 
@@ -22,7 +21,12 @@ app = typer.Typer(help="Convert an Arkos Tracker (.txt) file to CVBasic music.")
22
21
  def main(
23
22
  input_file: Annotated[
24
23
  Path,
25
- typer.Argument(help="Input Arkos Tracker file"),
24
+ typer.Argument(
25
+ help="Input Arkos Tracker file",
26
+ exists=True,
27
+ dir_okay=False,
28
+ readable=True,
29
+ ),
26
30
  ],
27
31
  output_file: Annotated[
28
32
  Path,
@@ -86,6 +90,14 @@ def main(
86
90
  "(positive = up, negative = down; 12 = +1 octave).",
87
91
  ),
88
92
  ] = 0,
93
+ no_instruments: Annotated[
94
+ bool,
95
+ typer.Option(
96
+ "--no-instruments",
97
+ help="Do not map Arkos instruments 1-3 to CVBasic W/X/Y; "
98
+ "all melodic notes use the default piano.",
99
+ ),
100
+ ] = False,
89
101
  ) -> None:
90
102
  """Command-line entry point."""
91
103
  if data_byte < 1:
@@ -95,16 +107,25 @@ def main(
95
107
  excluded: set[int] = set()
96
108
  for part in exclude_channels.split(","):
97
109
  part = part.strip()
98
- if part:
110
+ if not part:
111
+ continue
112
+ try:
99
113
  val = int(part)
100
- if val not in (1, 2, 3):
101
- typer.echo(
102
- f"Error: --exclude-channels only accepts values 1, 2, 3 "
103
- f"(got: {val})",
104
- err=True,
105
- )
106
- raise typer.Exit(1)
107
- excluded.add(val)
114
+ except ValueError:
115
+ typer.echo(
116
+ f"Error: --exclude-channels only accepts values 1, 2, 3 "
117
+ f"(got: '{part}')",
118
+ err=True,
119
+ )
120
+ raise typer.Exit(1)
121
+ if val not in (1, 2, 3):
122
+ typer.echo(
123
+ f"Error: --exclude-channels only accepts values 1, 2, 3 "
124
+ f"(got: {val})",
125
+ err=True,
126
+ )
127
+ raise typer.Exit(1)
128
+ excluded.add(val)
108
129
 
109
130
  importer = TXTImport(input_file=input_file)
110
131
 
@@ -113,26 +134,30 @@ def main(
113
134
  effective_transpose = (1 + octaves) * 12 + transpose
114
135
  importer.transpose(effective_transpose)
115
136
 
116
- log_buffer = StringIO()
117
- log_handler = logging.StreamHandler(log_buffer)
137
+ log_handler = logging.StreamHandler(sys.stdout)
118
138
  log_handler.setFormatter(logging.Formatter("%(message)s"))
119
139
  pkg_logger = logging.getLogger("arkos2basic")
120
140
  pkg_logger.addHandler(log_handler)
121
141
  pkg_logger.setLevel(logging.INFO)
122
142
 
123
- importer.export(
124
- output_file=output_file,
125
- label=label,
126
- data_byte=data_byte,
127
- length_scale=3.0 + length_scale,
128
- invert_speed=invert,
129
- stop=stop,
130
- drum_length=2 + drum_length,
131
- exclude_channels=excluded if excluded else None,
132
- )
133
-
134
- pkg_logger.removeHandler(log_handler)
135
- sys.stdout.write(log_buffer.getvalue())
143
+ exclude_set: set[int] | None = None
144
+ if excluded:
145
+ exclude_set = excluded
146
+
147
+ try:
148
+ importer.export(
149
+ output_file=output_file,
150
+ label=label,
151
+ data_byte=data_byte,
152
+ length_scale=3.0 + length_scale,
153
+ invert_speed=invert,
154
+ stop=stop,
155
+ drum_length=2 + drum_length,
156
+ exclude_channels=exclude_set,
157
+ map_instruments=not no_instruments,
158
+ )
159
+ finally:
160
+ pkg_logger.removeHandler(log_handler)
136
161
 
137
162
 
138
163
  if __name__ == "__main__":
@@ -0,0 +1,463 @@
1
+ """CVBasic music source generator.
2
+
3
+ Contains all functions needed to emit a CVBasic DATA BYTE / MUSIC block
4
+ from the parsed song structure (Cell, Song).
5
+ """
6
+
7
+ import logging
8
+ from operator import attrgetter
9
+
10
+ from arkos2basic.arkostracker.cell import Cell
11
+ from arkos2basic.arkostracker.song import Song
12
+
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ # CVBasic places the sharp AFTER the octave number (e.g. "A4#"),
18
+ # so each entry stores just the letter and a sharp flag.
19
+ NOTE_NAMES: list[tuple[str, bool]] = [
20
+ ("C", False),
21
+ ("C", True),
22
+ ("D", False),
23
+ ("D", True),
24
+ ("E", False),
25
+ ("F", False),
26
+ ("F", True),
27
+ ("G", False),
28
+ ("G", True),
29
+ ("A", False),
30
+ ("A", True),
31
+ ("B", False),
32
+ ]
33
+
34
+ MIN_OCTAVE: int = 2
35
+ MAX_OCTAVE: int = 6
36
+
37
+ # Positional mapping of the Arkos instrument index to the CVBasic
38
+ # instrument suffix, regardless of what the instrument actually is in
39
+ # the tracker. Z (bass) is excluded because the CVBasic player also
40
+ # transposes it two octaves down. Melodic instruments beyond 3 fall
41
+ # back to W (piano, the player default).
42
+ INSTRUMENT_SUFFIXES: dict[int, str] = {1: "W", 2: "X", 3: "Y"}
43
+ DEFAULT_SUFFIX: str = "W"
44
+
45
+
46
+ def note_to_cvbasic(note: int) -> str:
47
+ """Convert a MIDI note number to a CVBasic note name.
48
+
49
+ The note number must already include any transposition (semitone
50
+ shift + octave offset) before calling this function. The octave is
51
+ clamped to the 2-6 range if out of bounds.
52
+
53
+ Args:
54
+ note: MIDI-style note number (0 = C-0), already transposed.
55
+
56
+ Returns:
57
+ The note name in CVBasic format, e.g. "A4#".
58
+ """
59
+ letter, sharp = NOTE_NAMES[note % 12]
60
+ octave = note // 12
61
+
62
+ if octave < MIN_OCTAVE:
63
+ octave = MIN_OCTAVE
64
+ elif octave > MAX_OCTAVE:
65
+ octave = MAX_OCTAVE
66
+
67
+ suffix = ""
68
+ if sharp:
69
+ suffix = "#"
70
+
71
+ return f"{letter}{octave}{suffix}"
72
+
73
+
74
+ def speed_track_for(song: Song, pattern_index: int) -> dict[int, int]:
75
+ """Return the row -> speed mapping of the pattern's speed track.
76
+
77
+ Args:
78
+ song: the parsed song structure.
79
+ pattern_index: index of the pattern to look up.
80
+
81
+ Returns:
82
+ The row -> speed dict, or an empty dict if the pattern has no
83
+ speed track.
84
+ """
85
+ track_index = song.pattern_speed.get(pattern_index, -1)
86
+
87
+ return song.speed_tracks.get(track_index, {})
88
+
89
+
90
+ def state_at_position(
91
+ song: Song,
92
+ pos_start: int,
93
+ ) -> tuple[int, dict[int, int | None]]:
94
+ """Replay the song state up to (excluding) the given position.
95
+
96
+ Both the tracker speed and the forceInstrumentSpeed (sXX) effects
97
+ propagate across patterns, so a section converted on its own (e.g.
98
+ the loop after an intro) must start from the state reached at the
99
+ end of the previous section, not from the song defaults.
100
+
101
+ Args:
102
+ song: the parsed song structure.
103
+ pos_start: index of the first position the caller will convert.
104
+
105
+ Returns:
106
+ A tuple (current_speed, last_effects) where last_effects maps
107
+ each channel slot (0-based) to the last sXX value seen on it,
108
+ or None if the channel never had one.
109
+ """
110
+ current_speed = song.initial_speed
111
+ last_effects: dict[int, int | None] = {}
112
+
113
+ for pattern_index, height in song.positions[:pos_start]:
114
+ track = speed_track_for(song, pattern_index)
115
+ for row in range(height):
116
+ value = track.get(row, 0)
117
+ if value != 0:
118
+ current_speed = value
119
+
120
+ track_ids = song.patterns.get(pattern_index, [])
121
+ for slot, track_id in enumerate(track_ids):
122
+ cells = song.tracks.get(track_id, [])
123
+ speed_cells = [
124
+ c for c in cells if c.speed is not None and c.row < height
125
+ ]
126
+ if speed_cells:
127
+ speed_cells.sort(key=attrgetter("row"))
128
+ last_effects[slot] = speed_cells[-1].speed
129
+
130
+ return current_speed, last_effects
131
+
132
+
133
+ def build_channel(
134
+ cells: list[Cell],
135
+ height: int,
136
+ row_start: list[int],
137
+ length_scale: float,
138
+ invert_speed: bool,
139
+ drum_instruments: dict[int, str],
140
+ transpose: int = 0,
141
+ initial_effect: int | None = None,
142
+ initial_suffix: str | None = None,
143
+ map_instruments: bool = True,
144
+ stats: dict[str, int] | None = None,
145
+ ) -> tuple[list[str], int | None, str | None]:
146
+ """Expand a channel into CVBasic tokens following the step layout.
147
+
148
+ Notes played with instrument 0 (RST) or with a percussion instrument
149
+ are omitted from the melodic channel (left as silence "-"); percussion
150
+ notes are collected separately by build_drum_channel.
151
+
152
+ A note without a forceInstrumentSpeed effect inherits the last sXX
153
+ value seen on the channel, including values inherited from previous
154
+ patterns via initial_effect.
155
+
156
+ When map_instruments is True, the Arkos instrument index is mapped
157
+ positionally to the CVBasic instrument suffix (1 -> W, 2 -> X,
158
+ 3 -> Y, others -> W). Since CVBasic carries the instrument over per
159
+ channel, the suffix is emitted only when it differs from the last
160
+ one emitted (tracked via initial_suffix across patterns).
161
+
162
+ Args:
163
+ cells: channel cells (notes and speed-only effects).
164
+ height: number of rows in the pattern.
165
+ row_start: starting step index for each row (length height + 1).
166
+ length_scale: factor that lengthens the sounding portion of notes.
167
+ invert_speed: if True, high forceInstrumentSpeed = shorter note.
168
+ drum_instruments: map instrument_idx -> CVBasic type; notes with
169
+ these instruments are silenced in the melodic channel.
170
+ transpose: total semitones to add to the MIDI note number
171
+ (includes octave shift and fine semitone adjustment).
172
+ initial_effect: last sXX value inherited from previous patterns,
173
+ or None if the channel never had one.
174
+ initial_suffix: instrument suffix already emitted on the channel
175
+ in previous patterns, or None if none was emitted yet.
176
+ map_instruments: if False, never emit instrument suffixes
177
+ (all notes use the player default piano).
178
+ stats: optional accumulator with "notes" and "clipped" keys;
179
+ counts the melodic notes driven by an sXX > 0 and how many
180
+ of them had their duration clipped by the next note.
181
+
182
+ Returns:
183
+ A tuple (tokens, last_effect, last_suffix): the CVBasic tokens
184
+ of length row_start[height], the last sXX value on the channel
185
+ and the last emitted instrument suffix, both to be carried over
186
+ into the next pattern.
187
+ """
188
+ total = row_start[height]
189
+ tokens: list[str] = ["-"] * total
190
+
191
+ in_range = [c for c in cells if c.row < height]
192
+ note_cells = sorted(
193
+ (c for c in in_range if c.note >= 0), key=attrgetter("row")
194
+ )
195
+ speed_events = sorted(
196
+ (c for c in in_range if c.speed is not None), key=attrgetter("row")
197
+ )
198
+
199
+ last_effect = initial_effect
200
+ last_suffix = initial_suffix
201
+ event_pos = 0
202
+
203
+ for i, current in enumerate(note_cells):
204
+ while (
205
+ event_pos < len(speed_events)
206
+ and speed_events[event_pos].row <= current.row
207
+ ):
208
+ last_effect = speed_events[event_pos].speed
209
+ event_pos += 1
210
+
211
+ if current.speed is not None:
212
+ effective = current.speed
213
+ else:
214
+ effective = last_effect
215
+
216
+ if i + 1 < len(note_cells):
217
+ next_row = note_cells[i + 1].row
218
+ else:
219
+ next_row = height
220
+ start = row_start[current.row]
221
+ gap = row_start[next_row] - start
222
+
223
+ is_silent = (
224
+ current.instrument == 0 or current.instrument in drum_instruments
225
+ )
226
+
227
+ if effective is None or effective == 0:
228
+ audible = gap
229
+ else:
230
+ base = effective
231
+ if invert_speed:
232
+ base = max(1, 12 - effective)
233
+ length = max(1, round(base * length_scale))
234
+ audible = min(length, gap)
235
+ if stats is not None and not is_silent:
236
+ stats["notes"] += 1
237
+ if length > gap:
238
+ stats["clipped"] += 1
239
+
240
+ if not is_silent:
241
+ name = note_to_cvbasic(current.note + transpose)
242
+ if map_instruments:
243
+ suffix = INSTRUMENT_SUFFIXES.get(current.instrument)
244
+ if suffix is None:
245
+ if current.instrument >= 0:
246
+ suffix = DEFAULT_SUFFIX
247
+ elif last_suffix is not None:
248
+ suffix = last_suffix
249
+ else:
250
+ suffix = DEFAULT_SUFFIX
251
+ if suffix != last_suffix:
252
+ name = f"{name}{suffix}"
253
+ last_suffix = suffix
254
+ if stats is not None:
255
+ key = f"notes_{suffix}"
256
+ stats[key] = stats.get(key, 0) + 1
257
+ tokens[start] = name
258
+ for k in range(1, audible):
259
+ tokens[start + k] = "S"
260
+
261
+ while event_pos < len(speed_events):
262
+ last_effect = speed_events[event_pos].speed
263
+ event_pos += 1
264
+
265
+ return tokens, last_effect, last_suffix
266
+
267
+
268
+ def build_drum_channel(
269
+ channels_cells: list[list[Cell]],
270
+ row_start: list[int],
271
+ height: int,
272
+ drum_instruments: dict[int, str],
273
+ drum_length: int = 1,
274
+ ) -> list[str]:
275
+ """Build the percussion track (fourth MUSIC channel).
276
+
277
+ Scans all melodic channels of the pattern and places the percussion
278
+ type token at the step corresponding to the note's row, repeating it
279
+ for drum_length consecutive steps to make the hit more audible. If
280
+ more than one channel has a percussion note on the same row, the last
281
+ channel in the list overwrites the previous ones.
282
+
283
+ Args:
284
+ channels_cells: cells from each melodic channel of the pattern.
285
+ row_start: starting step index for each row.
286
+ height: number of rows in the pattern.
287
+ drum_instruments: map instrument_idx -> CVBasic type (M1/M2/M3).
288
+ drum_length: number of consecutive steps per percussion hit.
289
+
290
+ Returns:
291
+ List of tokens for the fourth channel, of length row_start[height].
292
+ """
293
+ total = row_start[height]
294
+ tokens: list[str] = ["-"] * total
295
+
296
+ for cells in channels_cells:
297
+ drum_cells = [
298
+ c for c in cells
299
+ if c.instrument in drum_instruments and c.row < height
300
+ ]
301
+ for cell in drum_cells:
302
+ drum_type = drum_instruments[cell.instrument]
303
+ start = row_start[cell.row]
304
+ for k in range(drum_length):
305
+ if start + k < total:
306
+ tokens[start + k] = drum_type
307
+
308
+ return tokens
309
+
310
+
311
+ def convert(
312
+ song: Song,
313
+ label: str,
314
+ data_byte: int,
315
+ length_scale: float,
316
+ invert_speed: bool,
317
+ stop: bool = False,
318
+ drum_length: int = 1,
319
+ pos_start: int = 0,
320
+ pos_end: int | None = None,
321
+ exclude_channels: set[int] | None = None,
322
+ transpose: int = 0,
323
+ map_instruments: bool = True,
324
+ ) -> tuple[str, set[int]]:
325
+ """Generate the complete CVBasic music source.
326
+
327
+ When pos_start > 0 (e.g. converting the loop section of a split
328
+ song), the speed and the per-channel sXX state are first replayed
329
+ through the skipped positions, so the section starts from the same
330
+ state it would have during normal playback.
331
+
332
+ Args:
333
+ song: the parsed song structure.
334
+ label: label to assign to the music block.
335
+ data_byte: frames per MUSIC step (the DATA BYTE value).
336
+ length_scale: factor that lengthens the sounding portion.
337
+ invert_speed: if True, high forceInstrumentSpeed = shorter note.
338
+ stop: if True, end with MUSIC STOP; otherwise with MUSIC REPEAT.
339
+ drum_length: consecutive steps per percussion hit.
340
+ pos_start: index of the first position to include.
341
+ pos_end: exclusive index of the last position; None uses
342
+ end_position of the song (or the full list).
343
+ exclude_channels: set of 1-based source channel indices to exclude
344
+ (1, 2, or 3). Remaining channels are packed left; empty
345
+ right-hand slots become '-'.
346
+ transpose: total semitones to add to all melodic notes.
347
+ map_instruments: if True, Arkos instruments 1-3 are mapped to
348
+ the CVBasic suffixes W/X/Y (see build_channel). The suffix
349
+ emission state starts fresh in every generated file, so the
350
+ first note of each channel always carries its suffix.
351
+
352
+ Returns:
353
+ A tuple (BASIC source string, set of speeds encountered).
354
+ """
355
+ excluded: set[int] = set()
356
+ if exclude_channels is not None:
357
+ excluded = exclude_channels
358
+
359
+ end = pos_end
360
+ if end is None:
361
+ end = song.playback_end
362
+ positions_to_play = song.positions[pos_start:end]
363
+
364
+ out: list[str] = []
365
+ out.append(f"{label}:")
366
+ out.append(
367
+ f"\tDATA BYTE {data_byte}"
368
+ f"\t' frames per step (steps/row = tracker speed / {data_byte})"
369
+ )
370
+
371
+ current_speed, last_effects = state_at_position(song, pos_start)
372
+ last_suffixes: dict[int, str | None] = {}
373
+ speeds_seen: set[int] = set()
374
+ has_drums = bool(song.drum_instruments)
375
+ stats: dict[str, int] = {"notes": 0, "clipped": 0}
376
+
377
+ for pattern_index, height in positions_to_play:
378
+ track = speed_track_for(song, pattern_index)
379
+
380
+ steps_per_row: list[int] = []
381
+ for row in range(height):
382
+ value = track.get(row, 0)
383
+ if value != 0:
384
+ current_speed = value
385
+ speeds_seen.add(current_speed)
386
+ steps_per_row.append(max(1, round(current_speed / data_byte)))
387
+
388
+ row_start = [0] * (height + 1)
389
+ for row in range(height):
390
+ row_start[row + 1] = row_start[row] + steps_per_row[row]
391
+ total = row_start[height]
392
+
393
+ track_ids = song.patterns.get(pattern_index, [])
394
+ all_cells = [song.tracks.get(tid, []) for tid in track_ids]
395
+
396
+ # Filter excluded channels (1-based); remaining ones pack left,
397
+ # empty right-hand slots stay '-'. The original slot index is
398
+ # kept to carry the per-channel sXX state across patterns.
399
+ kept = [
400
+ (slot, cells) for slot, cells in enumerate(all_cells)
401
+ if (slot + 1) not in excluded
402
+ ]
403
+ channels_cells = [cells for _, cells in kept]
404
+
405
+ channels: list[list[str]] = []
406
+ for slot, cells in kept:
407
+ tokens, last_effects[slot], last_suffixes[slot] = build_channel(
408
+ cells, height, row_start,
409
+ length_scale, invert_speed,
410
+ song.drum_instruments, transpose,
411
+ initial_effect=last_effects.get(slot),
412
+ initial_suffix=last_suffixes.get(slot),
413
+ map_instruments=map_instruments,
414
+ stats=stats,
415
+ )
416
+ channels.append(tokens)
417
+ while len(channels) < 3:
418
+ channels.append(["-"] * total)
419
+
420
+ if has_drums:
421
+ drums = build_drum_channel(
422
+ channels_cells, row_start, height,
423
+ song.drum_instruments, drum_length,
424
+ )
425
+
426
+ out.append(
427
+ f"\t' --- pattern {pattern_index} ({height} rows, "
428
+ f"speed {current_speed}) ---"
429
+ )
430
+ for s in range(total):
431
+ if has_drums:
432
+ out.append(
433
+ f"\tMUSIC {channels[0][s]},{channels[1][s]},"
434
+ f"{channels[2][s]},{drums[s]}"
435
+ )
436
+ else:
437
+ out.append(
438
+ f"\tMUSIC {channels[0][s]},{channels[1][s]},{channels[2][s]}"
439
+ )
440
+
441
+ if map_instruments:
442
+ counts: dict[str, int] = {}
443
+ for letter in ("W", "X", "Y"):
444
+ value = stats.get(f"notes_{letter}", 0)
445
+ if value > 0:
446
+ counts[letter] = value
447
+ if set(counts) - {"W"}:
448
+ logger.info("Instruments (%s): %s", label, counts)
449
+
450
+ if stats["notes"] > 0:
451
+ logger.info(
452
+ "Articulation (%s): %d of %d sXX notes clipped by the next "
453
+ "note. If many are clipped, sXX values sound the same: "
454
+ "lower --data-byte (1 = max resolution) and tune --length.",
455
+ label, stats["clipped"], stats["notes"],
456
+ )
457
+
458
+ ending = "REPEAT"
459
+ if stop:
460
+ ending = "STOP"
461
+ out.append(f"\tMUSIC {ending}")
462
+
463
+ return "\n".join(out) + "\n", speeds_seen
@@ -1,280 +0,0 @@
1
- """CVBasic music source generator.
2
-
3
- Contains all functions needed to emit a CVBasic DATA BYTE / MUSIC block
4
- from the parsed song structure (Cell, Song).
5
- """
6
-
7
- from arkos2basic.arkostracker.cell import Cell # noqa: F401
8
- from arkos2basic.arkostracker.song import Song # noqa: F401
9
-
10
-
11
- # CVBasic places the sharp AFTER the octave number (e.g. "A4#"),
12
- # so each entry stores just the letter and a sharp flag.
13
- NOTE_NAMES: list[tuple[str, bool]] = [
14
- ("C", False),
15
- ("C", True),
16
- ("D", False),
17
- ("D", True),
18
- ("E", False),
19
- ("F", False),
20
- ("F", True),
21
- ("G", False),
22
- ("G", True),
23
- ("A", False),
24
- ("A", True),
25
- ("B", False),
26
- ]
27
-
28
- MIN_OCTAVE: int = 2
29
- MAX_OCTAVE: int = 6
30
-
31
-
32
- def note_to_cvbasic(note: int) -> str:
33
- """Convert a MIDI note number to a CVBasic note name.
34
-
35
- The note number must already include any transposition (semitone
36
- shift + octave offset) before calling this function. The octave is
37
- clamped to the 2-6 range if out of bounds.
38
-
39
- Args:
40
- note: MIDI-style note number (0 = C-0), already transposed.
41
-
42
- Returns:
43
- The note name in CVBasic format, e.g. "A4#".
44
- """
45
- letter, sharp = NOTE_NAMES[note % 12]
46
- octave = note // 12
47
-
48
- if octave < MIN_OCTAVE:
49
- octave = MIN_OCTAVE
50
- elif octave > MAX_OCTAVE:
51
- octave = MAX_OCTAVE
52
-
53
- suffix = "#" if sharp else ""
54
-
55
- return f"{letter}{octave}{suffix}"
56
-
57
-
58
- def build_channel(
59
- cells: list[Cell],
60
- height: int,
61
- row_start: list[int],
62
- length_scale: float,
63
- invert_speed: bool,
64
- drum_instruments: dict[int, str],
65
- transpose: int = 0,
66
- ) -> list[str]:
67
- """Expand a channel into CVBasic tokens following the step layout.
68
-
69
- Notes played with instrument 0 (RST) or with a percussion instrument
70
- are omitted from the melodic channel (left as silence "-"); percussion
71
- notes are collected separately by build_drum_channel.
72
-
73
- Args:
74
- cells: channel cells (notes and speed-only effects).
75
- height: number of rows in the pattern.
76
- row_start: starting step index for each row (length height + 1).
77
- length_scale: factor that lengthens the sounding portion of notes.
78
- invert_speed: if True, high forceInstrumentSpeed = shorter note.
79
- drum_instruments: map instrument_idx -> CVBasic type; notes with
80
- these instruments are silenced in the melodic channel.
81
- transpose: total semitones to add to the MIDI note number
82
- (includes octave shift and fine semitone adjustment).
83
-
84
- Returns:
85
- List of CVBasic tokens of length row_start[height].
86
- """
87
- total = row_start[height]
88
- tokens: list[str] = ["-"] * total
89
-
90
- note_cells = [c for c in cells if c.note >= 0 and c.row < height]
91
- speed_at_row = {
92
- c.row: c.speed for c in cells if c.speed is not None and c.row < height
93
- }
94
-
95
- for i, current in enumerate(note_cells):
96
- last_speed: int | None = None
97
- for r in range(current.row + 1):
98
- if r in speed_at_row:
99
- last_speed = speed_at_row[r]
100
-
101
- effective = current.speed if current.speed is not None else last_speed
102
-
103
- next_row = note_cells[i + 1].row if i + 1 < len(note_cells) else height
104
- start = row_start[current.row]
105
- gap = row_start[next_row] - start
106
-
107
- if effective is None or effective == 0:
108
- audible = gap
109
- else:
110
- base = effective
111
- if invert_speed:
112
- base = max(1, 12 - effective)
113
- length = max(1, round(base * length_scale))
114
- audible = min(length, gap)
115
-
116
- is_silent = current.instrument == 0 or current.instrument in drum_instruments
117
- if not is_silent:
118
- tokens[start] = note_to_cvbasic(current.note + transpose)
119
- for k in range(1, audible):
120
- tokens[start + k] = "S"
121
-
122
- return tokens
123
-
124
-
125
- def build_drum_channel(
126
- channels_cells: list[list[Cell]],
127
- row_start: list[int],
128
- height: int,
129
- drum_instruments: dict[int, str],
130
- drum_length: int = 1,
131
- ) -> list[str]:
132
- """Build the percussion track (fourth MUSIC channel).
133
-
134
- Scans all melodic channels of the pattern and places the percussion
135
- type token at the step corresponding to the note's row, repeating it
136
- for drum_length consecutive steps to make the hit more audible. If
137
- more than one channel has a percussion note on the same row, the last
138
- channel in the list overwrites the previous ones.
139
-
140
- Args:
141
- channels_cells: cells from each melodic channel of the pattern.
142
- row_start: starting step index for each row.
143
- height: number of rows in the pattern.
144
- drum_instruments: map instrument_idx -> CVBasic type (M1/M2/M3).
145
- drum_length: number of consecutive steps per percussion hit.
146
-
147
- Returns:
148
- List of tokens for the fourth channel, of length row_start[height].
149
- """
150
- total = row_start[height]
151
- tokens: list[str] = ["-"] * total
152
-
153
- for cells in channels_cells:
154
- drum_cells = [
155
- c for c in cells
156
- if c.instrument in drum_instruments and c.row < height
157
- ]
158
- for cell in drum_cells:
159
- drum_type = drum_instruments[cell.instrument]
160
- start = row_start[cell.row]
161
- for k in range(drum_length):
162
- if start + k < total:
163
- tokens[start + k] = drum_type
164
-
165
- return tokens
166
-
167
-
168
- def convert(
169
- song: Song,
170
- label: str,
171
- data_byte: int,
172
- length_scale: float,
173
- invert_speed: bool,
174
- stop: bool = False,
175
- drum_length: int = 1,
176
- pos_start: int = 0,
177
- pos_end: int | None = None,
178
- exclude_channels: set[int] | None = None,
179
- transpose: int = 0,
180
- ) -> tuple[str, set[int]]:
181
- """Generate the complete CVBasic music source.
182
-
183
- Args:
184
- song: the parsed song structure.
185
- label: label to assign to the music block.
186
- data_byte: frames per MUSIC step (the DATA BYTE value).
187
- length_scale: factor that lengthens the sounding portion.
188
- invert_speed: if True, high forceInstrumentSpeed = shorter note.
189
- stop: if True, end with MUSIC STOP; otherwise with MUSIC REPEAT.
190
- drum_length: consecutive steps per percussion hit.
191
- pos_start: index of the first position to include.
192
- pos_end: exclusive index of the last position; None uses
193
- end_position of the song (or the full list).
194
- exclude_channels: set of 1-based source channel indices to exclude
195
- (1, 2, or 3). Remaining channels are packed left; empty
196
- right-hand slots become '-'.
197
- transpose: total semitones to add to all melodic notes.
198
-
199
- Returns:
200
- A tuple (BASIC source string, set of speeds encountered).
201
- """
202
- excluded: set[int] = exclude_channels if exclude_channels is not None else set()
203
-
204
- song_end = song.end_position + 1 if song.end_position >= 0 else len(song.positions)
205
- end = pos_end if pos_end is not None else song_end
206
- positions_to_play = song.positions[pos_start:end]
207
-
208
- out: list[str] = []
209
- out.append(f"{label}:")
210
- out.append(
211
- f"\tDATA BYTE {data_byte}"
212
- f"\t' frames per step (steps/row = tracker speed / {data_byte})"
213
- )
214
-
215
- current_speed = song.initial_speed
216
- speeds_seen: set[int] = set()
217
-
218
- for pattern_index, height in positions_to_play:
219
- track = song.speed_tracks.get(song.pattern_speed.get(pattern_index, -1), {})
220
-
221
- steps_per_row: list[int] = []
222
- for r in range(height):
223
- if r in track and track[r] != 0:
224
- current_speed = track[r]
225
- speeds_seen.add(current_speed)
226
- steps_per_row.append(max(1, round(current_speed / data_byte)))
227
-
228
- row_start = [0] * (height + 1)
229
- for r in range(height):
230
- row_start[r + 1] = row_start[r] + steps_per_row[r]
231
- total = row_start[height]
232
-
233
- track_ids = song.patterns.get(pattern_index, [])
234
- all_cells = [song.tracks.get(tid, []) for tid in track_ids]
235
-
236
- # Filter excluded channels (1-based); remaining ones pack left,
237
- # empty right-hand slots stay '-'.
238
- channels_cells = [
239
- cells for i, cells in enumerate(all_cells)
240
- if (i + 1) not in excluded
241
- ]
242
-
243
- channels: list[list[str]] = []
244
- for cells in channels_cells:
245
- channels.append(
246
- build_channel(
247
- cells, height, row_start,
248
- length_scale, invert_speed,
249
- song.drum_instruments, transpose,
250
- )
251
- )
252
- while len(channels) < 3:
253
- channels.append(["-"] * total)
254
-
255
- has_drums = bool(song.drum_instruments)
256
- if has_drums:
257
- drums = build_drum_channel(
258
- channels_cells, row_start, height,
259
- song.drum_instruments, drum_length,
260
- )
261
-
262
- out.append(
263
- f"\t' --- pattern {pattern_index} ({height} rows, "
264
- f"speed {steps_per_row and current_speed}) ---"
265
- )
266
- for s in range(total):
267
- if has_drums:
268
- out.append(
269
- f"\tMUSIC {channels[0][s]},{channels[1][s]},"
270
- f"{channels[2][s]},{drums[s]}"
271
- )
272
- else:
273
- out.append(
274
- f"\tMUSIC {channels[0][s]},{channels[1][s]},{channels[2][s]}"
275
- )
276
-
277
- ending = "STOP" if stop else "REPEAT"
278
- out.append(f"\tMUSIC {ending}")
279
-
280
- return "\n".join(out) + "\n", speeds_seen