arkos2basic 0.1.0b1__py3-none-any.whl
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/__init__.py +0 -0
- arkos2basic/arkostracker/__init__.py +1 -0
- arkos2basic/arkostracker/baseimport.py +155 -0
- arkos2basic/arkostracker/cell.py +22 -0
- arkos2basic/arkostracker/song.py +35 -0
- arkos2basic/arkostracker/txtimport.py +203 -0
- arkos2basic/cli.py +139 -0
- arkos2basic/cvbasic.py +280 -0
- arkos2basic-0.1.0b1.dist-info/METADATA +30 -0
- arkos2basic-0.1.0b1.dist-info/RECORD +12 -0
- arkos2basic-0.1.0b1.dist-info/WHEEL +4 -0
- arkos2basic-0.1.0b1.dist-info/entry_points.txt +3 -0
arkos2basic/__init__.py
ADDED
|
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"
|
arkos2basic/cli.py
ADDED
|
@@ -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()
|
arkos2basic/cvbasic.py
ADDED
|
@@ -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
|
|
@@ -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,12 @@
|
|
|
1
|
+
arkos2basic/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
arkos2basic/arkostracker/__init__.py,sha256=vGTepPGl1uP8IaF0M-nzK8pkcHkzBrOm-9f-h85otlg,41
|
|
3
|
+
arkos2basic/arkostracker/baseimport.py,sha256=V97ll-UwCYjGwQtByZdxrvVTEUTF5D1PvSfgan4X4bc,5288
|
|
4
|
+
arkos2basic/arkostracker/cell.py,sha256=sve7aVRcLZuIsj5h_G8EJBizTJY3FA_8OnJIY8JP70k,592
|
|
5
|
+
arkos2basic/arkostracker/song.py,sha256=siJYfIcy3jpinZMX3Zwpd--P8jecenUPdCwYYwoQrsw,1556
|
|
6
|
+
arkos2basic/arkostracker/txtimport.py,sha256=mNzt-hlTHn1a6y61H_dN5Zncs8CIKSb_0ns-oNQPxKQ,8133
|
|
7
|
+
arkos2basic/cli.py,sha256=ZfdOXRXas9UNZRxCGCtBroeEQ3s3_wSCN5gLxMEqphA,4088
|
|
8
|
+
arkos2basic/cvbasic.py,sha256=e5K1TYte9uxSquxlUsbByMdGgWFIbAwhKcd_yBXz-_A,9452
|
|
9
|
+
arkos2basic-0.1.0b1.dist-info/METADATA,sha256=ihSWXla_a8iQnstOGM_BZkiK0SLj5IdvngXoUUvEc4U,978
|
|
10
|
+
arkos2basic-0.1.0b1.dist-info/WHEEL,sha256=EGEvSphFYqXKs23-kQBeyNoJP1nrT8ZJKQoi5p5DYL8,88
|
|
11
|
+
arkos2basic-0.1.0b1.dist-info/entry_points.txt,sha256=K1SMVzS8DaYzJDo8RaW4ARMo4XlAeHOYoDnd20KJY68,51
|
|
12
|
+
arkos2basic-0.1.0b1.dist-info/RECORD,,
|