arkos2basic 0.1.0b1__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.
@@ -0,0 +1,30 @@
1
+ Metadata-Version: 2.4
2
+ Name: arkos2basic
3
+ Version: 0.1.0b1
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
+ Author: Francesco Maida
6
+ Author-email: francesco.maida@gmail.com
7
+ Requires-Python: >=3.12,<4
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Programming Language :: Python :: 3.13
11
+ Classifier: Programming Language :: Python :: 3.14
12
+ Requires-Dist: typer (>=0.26.7,<0.27.0)
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Arkos2Basic
16
+
17
+ 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.
18
+
19
+ ## Installation
20
+
21
+ ```
22
+ pipx install arkos2basic
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ```
28
+ arkos2basic <input-file> <output-file>
29
+ ```
30
+
@@ -0,0 +1,15 @@
1
+ # Arkos2Basic
2
+
3
+ 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.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ pipx install arkos2basic
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```
14
+ arkos2basic <input-file> <output-file>
15
+ ```
@@ -0,0 +1,22 @@
1
+ [project]
2
+ name = "arkos2basic"
3
+ version = "0.1.0b1"
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
+ authors = [
6
+ {name = "Francesco Maida",email = "francesco.maida@gmail.com"}
7
+ ]
8
+ readme = "README.md"
9
+ requires-python = ">=3.12,<4"
10
+ dependencies = [
11
+ "typer (>=0.26.7,<0.27.0)"
12
+ ]
13
+
14
+ [tool.poetry]
15
+ packages = [{include = "arkos2basic", from = "src"}]
16
+
17
+ [tool.poetry.scripts]
18
+ arkos2basic = "arkos2basic.cli:app"
19
+
20
+ [build-system]
21
+ requires = ["poetry-core>=2.0.0,<3.0.0"]
22
+ build-backend = "poetry.core.masonry.api"
File without changes
@@ -0,0 +1 @@
1
+ """Arkos Tracker parsing data models."""
@@ -0,0 +1,155 @@
1
+ """Base class for Arkos Tracker file importers."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from arkos2basic.arkostracker.song import Song
7
+ from arkos2basic.cvbasic import convert
8
+
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class BaseImport:
14
+ """Base importer for Arkos Tracker source files.
15
+
16
+ Subclasses implement parse() to read a specific file format and return
17
+ a Song. Everything else — transpose, split logic, CVBasic output — is
18
+ handled here and is available to every future importer for free.
19
+
20
+ Typical usage::
21
+
22
+ importer = TXTImport(input_file=path)
23
+ importer.transpose(effective_semitones)
24
+ importer.export(output_file=out, ...)
25
+
26
+ Attributes:
27
+ input_file: path to the source file.
28
+ """
29
+
30
+ def __init__(self, input_file: Path) -> None:
31
+ """Parse the source file and prepare the importer.
32
+
33
+ Args:
34
+ input_file: path to the source file to import.
35
+ """
36
+ self.input_file = input_file
37
+ self._song: Song = self.parse()
38
+ self._transpose: int = 0
39
+
40
+
41
+ def parse(self) -> Song:
42
+ """Read self.input_file and return the parsed Song.
43
+
44
+ Subclasses must override this method.
45
+
46
+ Returns:
47
+ A Song object with positions, patterns, tracks, and speeds.
48
+ """
49
+ raise NotImplementedError
50
+
51
+
52
+ def transpose(self, notes: int) -> None:
53
+ """Set the semitone shift applied to all melodic notes on export.
54
+
55
+ Args:
56
+ notes: total semitones to add (positive = up, negative = down;
57
+ 12 = one octave up).
58
+ """
59
+ self._transpose = notes
60
+
61
+
62
+ def export(
63
+ self,
64
+ output_file: Path,
65
+ label: str,
66
+ data_byte: int,
67
+ length_scale: float,
68
+ invert_speed: bool,
69
+ stop: bool,
70
+ drum_length: int,
71
+ exclude_channels: set[int] | None = None,
72
+ ) -> None:
73
+ """Convert the song to CVBasic and write the output file(s).
74
+
75
+ Handles the intro/loop split automatically: if loop_start_position
76
+ > 0 and stop is False, two files are written (intro + loop).
77
+ Otherwise a single file is written. Status messages are emitted
78
+ via the module logger at INFO level.
79
+
80
+ Args:
81
+ output_file: path for the main output .bas file.
82
+ label: label assigned to the music block.
83
+ data_byte: frames per MUSIC step.
84
+ length_scale: factor for note sounding duration.
85
+ invert_speed: if True, high forceInstrumentSpeed = shorter note.
86
+ stop: if True, always end with MUSIC STOP (no split).
87
+ drum_length: consecutive steps per percussion hit.
88
+ exclude_channels: 1-based source channel indices to skip.
89
+ """
90
+ song = self._song
91
+ do_split = song.loop_start_position > 0 and not stop
92
+
93
+ conv_args: dict = dict(
94
+ data_byte=data_byte,
95
+ length_scale=length_scale,
96
+ invert_speed=invert_speed,
97
+ drum_length=drum_length,
98
+ exclude_channels=exclude_channels,
99
+ transpose=self._transpose,
100
+ )
101
+
102
+ if do_split:
103
+ song_end = (
104
+ song.end_position + 1 if song.end_position >= 0
105
+ else len(song.positions)
106
+ )
107
+ source_intro, speeds_intro = convert(
108
+ song, label=label, stop=True,
109
+ pos_start=0, pos_end=song.loop_start_position,
110
+ **conv_args,
111
+ )
112
+ loop_label = f"{label}_loop"
113
+ source_loop, speeds_loop = convert(
114
+ song, label=loop_label, stop=False,
115
+ pos_start=song.loop_start_position, pos_end=song_end,
116
+ **conv_args,
117
+ )
118
+ speeds = speeds_intro | speeds_loop
119
+
120
+ output_file.write_text(source_intro, encoding="utf-8")
121
+ loop_path = output_file.with_stem(output_file.stem + "_loop")
122
+ loop_path.write_text(source_loop, encoding="utf-8")
123
+
124
+ intro_patterns = song.loop_start_position
125
+ loop_patterns = song_end - song.loop_start_position
126
+ logger.info(
127
+ "Split intro/loop: %d pattern(s) intro, %d pattern(s) loop.",
128
+ intro_patterns,
129
+ loop_patterns,
130
+ )
131
+ logger.info(" Intro -> %s (label: %s)", output_file, label)
132
+ logger.info(" Loop -> %s (label: %s)", loop_path, loop_label)
133
+ else:
134
+ source, speeds = convert(
135
+ song, label=label, stop=stop, pos_start=0, **conv_args,
136
+ )
137
+ output_file.write_text(source, encoding="utf-8")
138
+
139
+ music_lines = source.count("\tMUSIC ")
140
+ logger.info("Converted: %d patterns.", len(song.positions))
141
+ logger.info(
142
+ "DATA BYTE: %d -> MUSIC rows: %d", data_byte, music_lines
143
+ )
144
+ logger.info("Output written to: %s", output_file)
145
+
146
+ logger.info(
147
+ "Speeds found (tracker frames/row): %s", sorted(speeds)
148
+ )
149
+ if song.drum_instruments:
150
+ logger.info(
151
+ "Percussion instruments detected: %s",
152
+ dict(song.drum_instruments),
153
+ )
154
+ else:
155
+ logger.info("No percussion instruments detected.")
@@ -0,0 +1,22 @@
1
+ """Data model for a single Arkos Tracker cell."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class Cell:
8
+ """A single tracker cell.
9
+
10
+ Attributes:
11
+ row: row index in the pattern (vertical position).
12
+ note: MIDI-style note number, or -1 if absent.
13
+ speed: forceInstrumentSpeed value of the cell, or None.
14
+ instrument: instrument index (-1 if unspecified).
15
+ Instrument 0 ("Empty") signals an RST: the channel goes
16
+ silent from that row onward.
17
+ """
18
+
19
+ row: int
20
+ note: int
21
+ speed: int | None = None
22
+ instrument: int = -1
@@ -0,0 +1,35 @@
1
+ """Data model for the parsed song extracted from an Arkos Tracker file."""
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from arkos2basic.arkostracker.cell import Cell
6
+
7
+
8
+ @dataclass
9
+ class Song:
10
+ """Intermediate representation of the song extracted from the file.
11
+
12
+ Attributes:
13
+ positions: playback order as (patternIndex, height) tuples.
14
+ patterns: for each patternIndex the list of 3 trackIndexes.
15
+ pattern_speed: for each patternIndex the speed track index.
16
+ tracks: for each trackIndex the list of its cells.
17
+ speed_tracks: for each speed track the row -> speed mapping.
18
+ initial_speed: starting speed before any speed-track change.
19
+ drum_instruments: map instrument_idx -> CVBasic type (M1/M2/M3)
20
+ for instruments that use the noise generator.
21
+ loop_start_position: 0-based index of the position from which
22
+ the song loops; 0 means loop from the beginning.
23
+ end_position: 0-based index of the last position to play
24
+ (inclusive); -1 means use all positions.
25
+ """
26
+
27
+ positions: list[tuple[int, int]] = field(default_factory=list)
28
+ patterns: dict[int, list[int]] = field(default_factory=dict)
29
+ pattern_speed: dict[int, int] = field(default_factory=dict)
30
+ tracks: dict[int, list[Cell]] = field(default_factory=dict)
31
+ speed_tracks: dict[int, dict[int, int]] = field(default_factory=dict)
32
+ initial_speed: int = 6
33
+ drum_instruments: dict[int, str] = field(default_factory=dict)
34
+ loop_start_position: int = 0
35
+ end_position: int = -1
@@ -0,0 +1,203 @@
1
+ """Importer for the Arkos Tracker text export format (V1.0)."""
2
+
3
+ import re
4
+
5
+ from arkos2basic.arkostracker.baseimport import BaseImport
6
+ from arkos2basic.arkostracker.cell import Cell
7
+ from arkos2basic.arkostracker.song import Song
8
+
9
+
10
+ class TXTImport(BaseImport):
11
+ """Importer for the Arkos Tracker plain-text export (V1.0).
12
+
13
+ Handles only what is specific to the TXT format: reading the
14
+ SECTION/ENDSECTION tree and mapping it to a Song. Split logic,
15
+ transpose and CVBasic output are inherited from BaseImport.
16
+ """
17
+
18
+ def parse(self) -> Song:
19
+ """Read self.input_file and parse the Arkos Tracker text.
20
+
21
+ Returns:
22
+ A Song object with positions, patterns, tracks, and speeds.
23
+ """
24
+ text = self.input_file.read_text(encoding="utf-8")
25
+ song = Song()
26
+ lines = text.replace("\r\n", "\n").split("\n")
27
+
28
+ stack: list[str] = []
29
+ track_index: int | None = None
30
+ cell: Cell | None = None
31
+ in_effect = False
32
+ in_track_indexes = False
33
+ in_speed_index = False
34
+ pattern_tracks: list[int] = []
35
+ pattern_speed_index: int | None = None
36
+ pattern_count = 0
37
+ pos_pattern: int | None = None
38
+ pos_height: int | None = None
39
+ st_index: int | None = None
40
+ st_row: int | None = None
41
+
42
+ inst_index: int = 0
43
+ in_inst_cells: bool = False
44
+ inst_noise_values: list[int] = []
45
+ inst_cell_noise: int = 0
46
+
47
+ for raw in lines:
48
+ line = raw.strip()
49
+ if not line:
50
+ continue
51
+
52
+ begin = re.match(r"^(-*)SECTION\s+(\w+)", line)
53
+ end = re.match(r"^(-*)ENDSECTION\s+(\w+)", line)
54
+
55
+ if begin:
56
+ name = begin.group(2)
57
+ stack.append(name)
58
+
59
+ if name == "track":
60
+ track_index = None
61
+ elif name == "cell" and "track" in stack and "speedTrack" not in stack:
62
+ cell = Cell(row=0, note=-1)
63
+ elif name == "cell" and "speedTrack" in stack:
64
+ st_row = None
65
+ elif name == "effect":
66
+ in_effect = True
67
+ elif name == "trackIndexes":
68
+ in_track_indexes = True
69
+ elif name == "speedTrackIndex":
70
+ in_speed_index = True
71
+ elif name == "speedTrack":
72
+ st_index = None
73
+ elif name == "pattern":
74
+ pattern_tracks = []
75
+ pattern_speed_index = None
76
+ elif name == "position":
77
+ pos_pattern = None
78
+ pos_height = None
79
+ elif (name == "cells" and "instrument" in stack
80
+ and "track" not in stack):
81
+ in_inst_cells = True
82
+ inst_noise_values = []
83
+ elif name == "cell" and in_inst_cells:
84
+ inst_cell_noise = 0
85
+ continue
86
+
87
+ if end:
88
+ name = end.group(2)
89
+
90
+ if name == "cell" and "speedTrack" in stack:
91
+ st_row = None
92
+ elif name == "cell" and cell is not None and "track" in stack:
93
+ keep = cell.note >= 0 or cell.speed is not None
94
+ if keep and track_index is not None:
95
+ song.tracks.setdefault(track_index, []).append(cell)
96
+ cell = None
97
+ elif name == "cell" and in_inst_cells:
98
+ inst_noise_values.append(inst_cell_noise)
99
+ elif name == "effect":
100
+ in_effect = False
101
+ elif name == "trackIndexes":
102
+ in_track_indexes = False
103
+ elif name == "speedTrackIndex":
104
+ in_speed_index = False
105
+ elif name == "pattern":
106
+ song.patterns[pattern_count] = pattern_tracks
107
+ if pattern_speed_index is not None:
108
+ song.pattern_speed[pattern_count] = pattern_speed_index
109
+ pattern_count += 1
110
+ elif name == "position":
111
+ if pos_pattern is not None and pos_height is not None:
112
+ song.positions.append((pos_pattern, pos_height))
113
+ elif (name == "cells" and "instrument" in stack
114
+ and "track" not in stack):
115
+ in_inst_cells = False
116
+ elif name == "instrument" and "instruments" in stack:
117
+ noise_only = [v for v in inst_noise_values if v > 0]
118
+ if noise_only:
119
+ avg = sum(noise_only) / len(noise_only)
120
+ song.drum_instruments[inst_index] = (
121
+ self._noise_to_drum_type(avg)
122
+ )
123
+ inst_index += 1
124
+
125
+ if stack:
126
+ stack.pop()
127
+ continue
128
+
129
+ parts = line.split(None, 1)
130
+ key = parts[0]
131
+ value = parts[1] if len(parts) > 1 else ""
132
+ top = stack[-1] if stack else ""
133
+
134
+ if "speedTrack" in stack and top == "speedTrack" and key == "index":
135
+ st_index = int(value)
136
+ song.speed_tracks.setdefault(st_index, {})
137
+ elif "speedTrack" in stack and top == "cell" and key == "index":
138
+ st_row = int(value)
139
+ elif "speedTrack" in stack and top == "cell" and key == "value":
140
+ if st_index is not None and st_row is not None:
141
+ song.speed_tracks[st_index][st_row] = int(value)
142
+ elif top == "track" and key == "index":
143
+ track_index = int(value)
144
+ song.tracks.setdefault(track_index, [])
145
+ elif top == "cell" and cell is not None and key == "index":
146
+ cell.row = int(value)
147
+ elif top == "cell" and cell is not None and key == "note":
148
+ cell.note = int(value)
149
+ elif top == "cell" and cell is not None and key == "instrument":
150
+ cell.instrument = int(value)
151
+ elif in_effect and cell is not None and key == "logicalValue":
152
+ cell.speed = int(value)
153
+ elif in_speed_index and key == "trackIndex":
154
+ pattern_speed_index = int(value)
155
+ elif in_track_indexes and key == "trackIndex":
156
+ pattern_tracks.append(int(value))
157
+ elif top == "position" and key == "patternIndex":
158
+ pos_pattern = int(value)
159
+ elif top == "position" and key == "height":
160
+ pos_height = int(value)
161
+ elif top == "subsong" and key == "initialSpeed":
162
+ song.initial_speed = int(value)
163
+ elif top == "subsong" and key == "loopStartPosition":
164
+ song.loop_start_position = int(value)
165
+ elif top == "subsong" and key == "endPosition":
166
+ song.end_position = int(value)
167
+ elif in_inst_cells and top == "cell" and key == "noise":
168
+ inst_cell_noise = int(value)
169
+
170
+ return song
171
+
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
+ def _noise_to_drum_type(self, avg_noise: float) -> str:
184
+ """Map the average noise period of an instrument to a CVBasic drum type.
185
+
186
+ The AY-3-8910 noise period ranges from 1 (high frequency) to 31
187
+ (low frequency). The mapping is approximate:
188
+ - 1-5 -> M2 (tap, snare / hi-hat)
189
+ - 6-15 -> M1 (strong, bass drum)
190
+ - 16+ -> M3 (roll, low noise)
191
+
192
+ Args:
193
+ avg_noise: average of the non-zero noise periods of the instrument.
194
+
195
+ Returns:
196
+ String "M1", "M2" or "M3" for use as the fourth MUSIC argument.
197
+ """
198
+ if avg_noise <= 5:
199
+ return "M2"
200
+ elif avg_noise <= 15:
201
+ return "M1"
202
+
203
+ return "M3"
@@ -0,0 +1,139 @@
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 io import StringIO
10
+ from pathlib import Path
11
+ from typing import Annotated
12
+
13
+ import typer
14
+
15
+ from arkos2basic.arkostracker.txtimport import TXTImport
16
+
17
+
18
+ app = typer.Typer(help="Convert an Arkos Tracker (.txt) file to CVBasic music.")
19
+
20
+
21
+ @app.command()
22
+ def main(
23
+ input_file: Annotated[
24
+ Path,
25
+ typer.Argument(help="Input Arkos Tracker file"),
26
+ ],
27
+ output_file: Annotated[
28
+ Path,
29
+ typer.Argument(help="Output .bas file"),
30
+ ],
31
+ octaves: Annotated[
32
+ int,
33
+ typer.Option(
34
+ help="Octave delta relative to base +1 "
35
+ "(e.g. --octaves 1 → +2 octaves, --octaves -1 → 0)"
36
+ ),
37
+ ] = 0,
38
+ data_byte: Annotated[
39
+ int,
40
+ typer.Option(
41
+ help="Frames per MUSIC step: lower = more faithful but more rows "
42
+ "(1 = exact timing)"
43
+ ),
44
+ ] = 2,
45
+ length_scale: Annotated[
46
+ float,
47
+ typer.Option(
48
+ "--length",
49
+ help="Delta from base 3.0 for note sounding duration "
50
+ "(e.g. --length 1 → 4.0, --length -1 → 2.0)",
51
+ ),
52
+ ] = 0.0,
53
+ invert: Annotated[
54
+ bool,
55
+ typer.Option(
56
+ help="Invert the scale: high forceInstrumentSpeed = shorter note"
57
+ ),
58
+ ] = False,
59
+ label: Annotated[
60
+ str,
61
+ typer.Option(help="Label for the music block in the output source"),
62
+ ] = "musica",
63
+ stop: Annotated[
64
+ bool,
65
+ typer.Option("--stop", help="End with MUSIC STOP instead of MUSIC REPEAT"),
66
+ ] = False,
67
+ drum_length: Annotated[
68
+ int,
69
+ typer.Option(
70
+ help="Delta from base 2 steps per percussion hit "
71
+ "(e.g. --drum-length 1 → 3, --drum-length -1 → 1)",
72
+ ),
73
+ ] = 0,
74
+ exclude_channels: Annotated[
75
+ str,
76
+ typer.Option(
77
+ "--exclude-channels",
78
+ help="Source channels to exclude, comma-separated "
79
+ "(1-3, e.g. '2' or '2,3'). Remaining channels pack left.",
80
+ ),
81
+ ] = "",
82
+ transpose: Annotated[
83
+ int,
84
+ typer.Option(
85
+ help="Chromatic semitones to add to all notes "
86
+ "(positive = up, negative = down; 12 = +1 octave).",
87
+ ),
88
+ ] = 0,
89
+ ) -> None:
90
+ """Command-line entry point."""
91
+ if data_byte < 1:
92
+ typer.echo("Error: --data-byte must be at least 1", err=True)
93
+ raise typer.Exit(1)
94
+
95
+ excluded: set[int] = set()
96
+ for part in exclude_channels.split(","):
97
+ part = part.strip()
98
+ if part:
99
+ 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)
108
+
109
+ importer = TXTImport(input_file=input_file)
110
+
111
+ # --octaves N contributes (1 + N) * 12 semitones (base 1 octave + delta).
112
+ # --transpose M adds M fine semitones on top.
113
+ effective_transpose = (1 + octaves) * 12 + transpose
114
+ importer.transpose(effective_transpose)
115
+
116
+ log_buffer = StringIO()
117
+ log_handler = logging.StreamHandler(log_buffer)
118
+ log_handler.setFormatter(logging.Formatter("%(message)s"))
119
+ pkg_logger = logging.getLogger("arkos2basic")
120
+ pkg_logger.addHandler(log_handler)
121
+ pkg_logger.setLevel(logging.INFO)
122
+
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())
136
+
137
+
138
+ if __name__ == "__main__":
139
+ app()
@@ -0,0 +1,280 @@
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