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.
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/PKG-INFO +5 -3
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/README.md +1 -1
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/pyproject.toml +8 -2
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/arkostracker/baseimport.py +8 -5
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/arkostracker/song.py +13 -0
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/arkostracker/txtimport.py +0 -10
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/cli.py +51 -26
- arkos2basic-0.1.0b6/src/arkos2basic/cvbasic.py +463 -0
- arkos2basic-0.1.0b4/src/arkos2basic/cvbasic.py +0 -280
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/__init__.py +0 -0
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/arkostracker/__init__.py +0 -0
- {arkos2basic-0.1.0b4 → arkos2basic-0.1.0b6}/src/arkos2basic/arkostracker/cell.py +0 -0
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: arkos2basic
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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.
|
|
94
|
+
Python 3.10 or better.
|
|
93
95
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "arkos2basic"
|
|
3
|
-
version = "0.1.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|