badline 0.6.1 → 0.8.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/CLAUDE.md +3 -1
- data/README.md +132 -247
- data/doc/library.md +62 -0
- data/doc/media.md +69 -0
- data/doc/sid-player.md +132 -0
- data/doc/snapshots.md +69 -0
- data/exe/badline-ruby +9 -1
- data/lib/badline/address_bus/extra_sids.rb +30 -0
- data/lib/badline/address_bus/sid_slots.rb +42 -0
- data/lib/badline/address_bus.rb +5 -0
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +153 -58
- data/lib/badline/audio/jukebox.rb +121 -36
- data/lib/badline/audio/machine_player.rb +25 -10
- data/lib/badline/audio/pcm_writer.rb +10 -6
- data/lib/badline/audio/playback.rb +6 -5
- data/lib/badline/audio/queued_tune.rb +89 -0
- data/lib/badline/audio/renderer.rb +57 -10
- data/lib/badline/audio/stereo.rb +97 -0
- data/lib/badline/audio/terminal.rb +39 -6
- data/lib/badline/audio/wav.rb +1 -1
- data/lib/badline/audio.rb +2 -0
- data/lib/badline/cartridge/freezer.rb +2 -0
- data/lib/badline/cartridge.rb +3 -0
- data/lib/badline/frontend/app.rb +38 -35
- data/lib/badline/frontend/audio_sink.rb +6 -5
- data/lib/badline/frontend/boot.rb +9 -2
- data/lib/badline/frontend/buttons.rb +184 -0
- data/lib/badline/frontend/confirmation.rb +50 -0
- data/lib/badline/frontend/controls.rb +44 -32
- data/lib/badline/frontend/file_browser.rb +225 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_report.rb +52 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/menu_dialogs.rb +122 -0
- data/lib/badline/frontend/menu_media.rb +128 -0
- data/lib/badline/frontend/menu_pages.rb +198 -0
- data/lib/badline/frontend/painter.rb +185 -0
- data/lib/badline/frontend/pause_menu.rb +246 -0
- data/lib/badline/frontend/player_commands.rb +33 -0
- data/lib/badline/frontend/player_footer.rb +86 -0
- data/lib/badline/frontend/player_header.rb +60 -0
- data/lib/badline/frontend/player_screen.rb +124 -0
- data/lib/badline/frontend/player_state.rb +67 -0
- data/lib/badline/frontend/player_window.rb +228 -0
- data/lib/badline/frontend/scope.rb +42 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -0
- data/lib/badline/frontend/snapshots.rb +1 -1
- data/lib/badline/frontend/sound.rb +0 -1
- data/lib/badline/frontend/timeline.rb +5 -1
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +21 -0
- data/lib/badline/media/disk_set.rb +108 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +14 -8
- data/lib/badline/options/help.rb +34 -5
- data/lib/badline/options/option.rb +1 -1
- data/lib/badline/options/table.rb +5 -3
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +65 -46
- data/lib/badline/sdl.rb +16 -6
- data/lib/badline/sid/catch_up.rb +11 -1
- data/lib/badline/sid/filter.rb +9 -0
- data/lib/badline/sid/voice.rb +6 -0
- data/lib/badline/sid/waveform.rb +20 -5
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/storage/hvsc.rb +41 -0
- data/lib/badline/storage/sid_file.rb +45 -25
- data/lib/badline/storage/song_lengths.rb +6 -29
- data/lib/badline/storage/stil/credit.rb +50 -0
- data/lib/badline/storage/stil.rb +126 -0
- data/lib/badline/storage.rb +8 -0
- data/lib/badline/version.rb +1 -1
- metadata +36 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's detailed view of one SID: each voice's note,
|
|
6
|
+
# registers and envelope over a scope of its output, then the filter (FilterView) and the scope of
|
|
7
|
+
# the SID's own output. `sid` picks which of a tune's SIDs it shows, and
|
|
8
|
+
# a tune on more than one gets a row of buttons above the voices that
|
|
9
|
+
# pick it, which makes the view PICKER taller.
|
|
10
|
+
class SIDView
|
|
11
|
+
COLORS = Screen::COLORS
|
|
12
|
+
TEXT = COLORS[14]
|
|
13
|
+
BRIGHT = COLORS[1]
|
|
14
|
+
DIM = COLORS[11]
|
|
15
|
+
BOX = COLORS[0]
|
|
16
|
+
VOICE_COLORS = [COLORS[7], COLORS[13], COLORS[3]].freeze
|
|
17
|
+
|
|
18
|
+
NOTES = %w[C C# D D# E F F# G G# A A# B].freeze
|
|
19
|
+
WAVES = %w[TRI SAW PUL NOI].freeze
|
|
20
|
+
FLAGS = %w[GATE SYNC RING TEST].freeze
|
|
21
|
+
PHASES = { attack: "ATTACK", decay_sustain: "DECAY/SUSTAIN", release: "RELEASE" }.freeze
|
|
22
|
+
|
|
23
|
+
LEFT = 16
|
|
24
|
+
WAVE_LEFT = 432
|
|
25
|
+
WAVE_WIDTH = 192
|
|
26
|
+
VOICE_HEIGHT = 64
|
|
27
|
+
PANEL_HEIGHT = 72
|
|
28
|
+
HEIGHT = (3 * VOICE_HEIGHT) + PANEL_HEIGHT + 12
|
|
29
|
+
PICKER = 20
|
|
30
|
+
|
|
31
|
+
SID_NAMES = ["SID 1", "SID 2", "SID 3"].freeze
|
|
32
|
+
SIDS = %i[sid1 sid2 sid3].freeze
|
|
33
|
+
|
|
34
|
+
# The nearest note to `hertz` and how many cents off it it is.
|
|
35
|
+
def self.note(hertz)
|
|
36
|
+
return "---" if hertz < 16.0
|
|
37
|
+
|
|
38
|
+
midi = 69 + (12 * Math.log(hertz / 440.0) / Math.log(2))
|
|
39
|
+
key = midi.round
|
|
40
|
+
cents = ((midi - key) * 100).round
|
|
41
|
+
name = NOTES[key % 12] + ((key / 12) - 1).to_s
|
|
42
|
+
name.ljust(3) + format("%<sign>s%<cents>02d", sign: cents.negative? ? "-" : "+", cents: cents.abs)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# The view's height for a tune on `sids` SIDs.
|
|
46
|
+
def self.height(sids) = sids > 1 ? HEIGHT + PICKER : HEIGHT
|
|
47
|
+
|
|
48
|
+
def initialize(painter, buttons, top)
|
|
49
|
+
@painter = painter
|
|
50
|
+
@buttons = buttons
|
|
51
|
+
@top = top
|
|
52
|
+
@filter = FilterView.new(painter)
|
|
53
|
+
@voices = Array.new(2) do |picker|
|
|
54
|
+
Array.new(3) do |voice|
|
|
55
|
+
Scope.new(painter, [WAVE_LEFT, top + (picker * PICKER) + (voice * VOICE_HEIGHT), WAVE_WIDTH,
|
|
56
|
+
VOICE_HEIGHT - 12], span: 512, range: 9216, centred: true)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
@outputs = Array.new(2) do |picker|
|
|
60
|
+
Scope.new(painter, [WAVE_LEFT, top + (picker * PICKER) + (3 * VOICE_HEIGHT) + 4, WAVE_WIDTH, PANEL_HEIGHT],
|
|
61
|
+
span: 1024, range: 65_536)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def draw(history, played, clock_hz:, model:, sid: 0)
|
|
66
|
+
return if history.empty?
|
|
67
|
+
|
|
68
|
+
picker = history.sid_count > 1 ? 1 : 0
|
|
69
|
+
draw_picker(history.sid_count, sid) if picker == 1
|
|
70
|
+
top = @top + (picker * PICKER)
|
|
71
|
+
frame = history.frame(played, sid)
|
|
72
|
+
3.times { |voice| draw_voice(history, frame, voice, top + (voice * VOICE_HEIGHT), clock_hz) }
|
|
73
|
+
3.times { |voice| @voices[picker][voice].draw(history, played, (sid * 4) + voice, VOICE_COLORS[voice]) }
|
|
74
|
+
@filter.draw(history, frame, top + (3 * VOICE_HEIGHT) + 4, model)
|
|
75
|
+
@outputs[picker].draw(history, played, history.output(sid), BRIGHT)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def draw_picker(sids, shown)
|
|
81
|
+
x = LEFT
|
|
82
|
+
sids.times do |sid|
|
|
83
|
+
x += @buttons.text(x, @top, SID_NAMES[sid], SIDS[sid], on: sid == shown) + 4
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def draw_voice(history, frame, voice, top, clock_hz)
|
|
88
|
+
base = voice * 7
|
|
89
|
+
color = VOICE_COLORS[voice]
|
|
90
|
+
control = history.register(frame, base + 4)
|
|
91
|
+
hertz = history.hertz(frame, voice, clock_hz)
|
|
92
|
+
paint = @painter
|
|
93
|
+
paint.text(LEFT, top, "VOICE #{voice + 1}", color)
|
|
94
|
+
paint.text(LEFT, top + 12, SIDView.note(hertz), history.level(frame, voice).zero? ? DIM : color, scale: 2)
|
|
95
|
+
paint.text(LEFT + 112, top + 12, format("%<hertz>.1f HZ", hertz:), TEXT)
|
|
96
|
+
draw_waves(control, top, color)
|
|
97
|
+
draw_flags(history, frame, voice, control, top + 24)
|
|
98
|
+
draw_envelope(history, frame, voice, top + 36, color)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def draw_waves(control, top, color)
|
|
102
|
+
x = LEFT + 224
|
|
103
|
+
WAVES.each_with_index do |wave, bit|
|
|
104
|
+
x += @painter.text(x, top, wave, control[bit + 4] == 1 ? color : DIM) + 8
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def draw_flags(history, frame, voice, control, top)
|
|
109
|
+
x = LEFT + 112
|
|
110
|
+
FLAGS.each_with_index do |flag, bit|
|
|
111
|
+
x += @painter.text(x, top, flag, control[bit] == 1 ? BRIGHT : DIM) + 8
|
|
112
|
+
end
|
|
113
|
+
filtered = history.register(frame, 0x17)[voice] == 1
|
|
114
|
+
@painter.text(x, top, "FILT", filtered ? BRIGHT : DIM)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def draw_envelope(history, frame, voice, top, color)
|
|
118
|
+
base = voice * 7
|
|
119
|
+
attack_decay = history.register(frame, base + 5)
|
|
120
|
+
sustain_release = history.register(frame, base + 6)
|
|
121
|
+
paint = @painter
|
|
122
|
+
paint.text(LEFT + 112, top, format("PW %<pw>5.1f%%", pw: pulse_width(history, frame, voice) * 100.0 / 4096),
|
|
123
|
+
TEXT)
|
|
124
|
+
rates = "A#{hex(attack_decay >> 4)} D#{hex(attack_decay & 0x0f)} " \
|
|
125
|
+
"S#{hex(sustain_release >> 4)} R#{hex(sustain_release & 0x0f)}"
|
|
126
|
+
paint.text(LEFT + 224, top, rates, TEXT)
|
|
127
|
+
paint.box(LEFT, top + 12, 128, 6, BOX)
|
|
128
|
+
paint.box(LEFT, top + 12, history.level(frame, voice) / 2, 6, color)
|
|
129
|
+
paint.box(LEFT + ((sustain_release >> 4) * 17 / 2), top + 10, 1, 10, BRIGHT)
|
|
130
|
+
paint.text(LEFT + 136, top + 12, PHASES.fetch(history.state(frame, voice), ""), DIM)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def hex(nibble) = nibble.to_s(16).upcase
|
|
134
|
+
|
|
135
|
+
def pulse_width(history, frame, voice)
|
|
136
|
+
history.register(frame, (voice * 7) + 2) | ((history.register(frame, (voice * 7) + 3) & 0x0f) << 8)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
@@ -15,7 +15,7 @@ module Badline
|
|
|
15
15
|
unless options.screenshot.empty? || options.frames.zero?
|
|
16
16
|
@events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
|
|
17
17
|
end
|
|
18
|
-
@read_only = options.
|
|
18
|
+
@read_only = !options.writable?
|
|
19
19
|
@program = options.program
|
|
20
20
|
@releases = []
|
|
21
21
|
@failed = false
|
|
@@ -63,6 +63,10 @@ module Badline
|
|
|
63
63
|
.map { |event| Timeline.path(event.argument, frame) }
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
# Presses the freeze button after `frame`, and lets go of it as the
|
|
67
|
+
# events do.
|
|
68
|
+
def press_freeze(computer, frame) = freeze(computer, Options::Event.new(frame, "freeze", ""))
|
|
69
|
+
|
|
66
70
|
# Whether the run ends at the frame: at a quit, or once an event failed.
|
|
67
71
|
def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
|
|
68
72
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's main view: each voice's note over a scope of its own
|
|
6
|
+
# output, and the mix below them. A tune on more than one SID gets a
|
|
7
|
+
# row of voices for each, shrunk to fit and labelled with the SID's
|
|
8
|
+
# number, and the mix split into its left and right.
|
|
9
|
+
class VisualizerView
|
|
10
|
+
HEIGHT = 176
|
|
11
|
+
VOICE_WIDTH = 192
|
|
12
|
+
VOICE_GAP = 16
|
|
13
|
+
MIX_WIDTH = 608
|
|
14
|
+
ROW_GAP = 4
|
|
15
|
+
|
|
16
|
+
# The height of each voice's scope, by how many SIDs there are.
|
|
17
|
+
VOICE_SCOPES = [64, 40, 28].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(painter, top)
|
|
20
|
+
@painter = painter
|
|
21
|
+
@top = top
|
|
22
|
+
@voices = Array.new(VOICE_SCOPES.size) { |index| voice_scopes(index + 1) }
|
|
23
|
+
@mix = Scope.new(painter, [SIDView::LEFT, mix_top(1), MIX_WIDTH, mix_height(1)],
|
|
24
|
+
span: 1024, range: 32_768, centred: true)
|
|
25
|
+
@sides = Array.new(VOICE_SCOPES.size - 1) { |index| side_scopes(index + 2) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def draw(history, played, clock_hz:)
|
|
29
|
+
return if history.empty?
|
|
30
|
+
|
|
31
|
+
count = history.sid_count
|
|
32
|
+
count.times { |sid| draw_row(history, played, sid, clock_hz) }
|
|
33
|
+
draw_mix(history, played, count)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def left(voice) = SIDView::LEFT + (voice * (VOICE_WIDTH + VOICE_GAP))
|
|
39
|
+
|
|
40
|
+
# The height of a row's notes, in glyphs twice the size for one SID.
|
|
41
|
+
def note_height(count) = count == 1 ? 20 : 10
|
|
42
|
+
|
|
43
|
+
def row_height(count) = note_height(count) + VOICE_SCOPES[count - 1] + ROW_GAP
|
|
44
|
+
|
|
45
|
+
def row_top(count, sid) = @top + (sid * row_height(count))
|
|
46
|
+
|
|
47
|
+
def mix_top(count) = row_top(count, count) + 4
|
|
48
|
+
|
|
49
|
+
def mix_height(count) = @top + HEIGHT - 8 - mix_top(count)
|
|
50
|
+
|
|
51
|
+
def voice_scopes(count)
|
|
52
|
+
Array.new(count * 3) do |index|
|
|
53
|
+
Scope.new(@painter, [left(index % 3), row_top(count, index / 3) + note_height(count), VOICE_WIDTH,
|
|
54
|
+
VOICE_SCOPES[count - 1]], span: 512, range: 9216, centred: true)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def side_scopes(count)
|
|
59
|
+
width = (MIX_WIDTH - VOICE_GAP) / 2
|
|
60
|
+
Array.new(2) do |side|
|
|
61
|
+
Scope.new(@painter, [SIDView::LEFT + (side * (width + VOICE_GAP)), mix_top(count), width, mix_height(count)],
|
|
62
|
+
span: 1024, range: 32_768, centred: true)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def draw_row(history, played, sid, clock_hz)
|
|
67
|
+
count = history.sid_count
|
|
68
|
+
frame = history.frame(played, sid)
|
|
69
|
+
top = row_top(count, sid)
|
|
70
|
+
scopes = @voices[count - 1]
|
|
71
|
+
3.times do |voice|
|
|
72
|
+
color = SIDView::VOICE_COLORS[voice]
|
|
73
|
+
note = SIDView.note(history.hertz(frame, voice, clock_hz))
|
|
74
|
+
silent = history.level(frame, voice).zero?
|
|
75
|
+
@painter.text(left(voice), top, note, silent ? SIDView::DIM : color, scale: count == 1 ? 2 : 1)
|
|
76
|
+
scopes[(sid * 3) + voice].draw(history, played, (sid * 4) + voice, color)
|
|
77
|
+
end
|
|
78
|
+
draw_label(top, sid) if count > 1
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def draw_label(top, sid)
|
|
82
|
+
label = "SID #{sid + 1}"
|
|
83
|
+
@painter.text(SIDView::LEFT + MIX_WIDTH - Painter.width(label), top, label, SIDView::TEXT)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def draw_mix(history, played, count)
|
|
87
|
+
if count == 1
|
|
88
|
+
@mix.draw(history, played, SIDHistory::MIX, SIDView::BRIGHT)
|
|
89
|
+
return
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
sides = @sides[count - 2]
|
|
93
|
+
sides[0].draw(history, played, history.left, SIDView::BRIGHT)
|
|
94
|
+
sides[1].draw(history, played, history.right, SIDView::BRIGHT)
|
|
95
|
+
top = mix_top(count) + 2
|
|
96
|
+
@painter.text(SIDView::LEFT + 2, top, "L", SIDView::DIM)
|
|
97
|
+
@painter.text(SIDView::LEFT + MIX_WIDTH - 10, top, "R", SIDView::DIM)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
data/lib/badline/frontend.rb
CHANGED
|
@@ -11,8 +11,29 @@ require "badline/frontend/screenshot"
|
|
|
11
11
|
require "badline/frontend/timeline"
|
|
12
12
|
require "badline/frontend/sound"
|
|
13
13
|
require "badline/frontend/pacer"
|
|
14
|
+
require "badline/frontend/frame_report"
|
|
14
15
|
require "badline/frontend/controls"
|
|
15
16
|
require "badline/frontend/pad_port"
|
|
16
17
|
require "badline/frontend/gamepads"
|
|
17
18
|
require "badline/frontend/app"
|
|
18
19
|
require "badline/frontend/audio_sink"
|
|
20
|
+
require "badline/frontend/painter"
|
|
21
|
+
require "badline/frontend/sid_history"
|
|
22
|
+
require "badline/frontend/sid_view"
|
|
23
|
+
require "badline/frontend/filter_view"
|
|
24
|
+
require "badline/frontend/scope"
|
|
25
|
+
require "badline/frontend/visualizer_view"
|
|
26
|
+
require "badline/frontend/buttons"
|
|
27
|
+
require "badline/frontend/player_state"
|
|
28
|
+
require "badline/frontend/player_header"
|
|
29
|
+
require "badline/frontend/player_footer"
|
|
30
|
+
require "badline/frontend/info_view"
|
|
31
|
+
require "badline/frontend/player_commands"
|
|
32
|
+
require "badline/frontend/player_screen"
|
|
33
|
+
require "badline/frontend/player_window"
|
|
34
|
+
require "badline/frontend/pause_menu"
|
|
35
|
+
require "badline/frontend/file_browser"
|
|
36
|
+
require "badline/frontend/confirmation"
|
|
37
|
+
require "badline/frontend/menu_dialogs"
|
|
38
|
+
require "badline/frontend/menu_media"
|
|
39
|
+
require "badline/frontend/menu_pages"
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Media
|
|
5
|
+
# The disks of a set a disk image belongs to, found by name in its
|
|
6
|
+
# folder: images whose names differ only in their disk, side or part
|
|
7
|
+
# markers, such as "Disk 1", "Side B", "d2", "(Disk 1 of 3)", ordered
|
|
8
|
+
# by those markers. A name with no marker can end in a number or a
|
|
9
|
+
# letter, as in game_1.d64 and game_2.d64, which counts only when the
|
|
10
|
+
# folder has the first of the set too.
|
|
11
|
+
module DiskSet
|
|
12
|
+
KINDS = %w[disk disc side part].freeze
|
|
13
|
+
EXTENSIONS = %w[.d64 .d71 .d81 .g64].freeze
|
|
14
|
+
|
|
15
|
+
# The paths of the set's disks in order, or just `path` when it
|
|
16
|
+
# belongs to none.
|
|
17
|
+
def self.around(path)
|
|
18
|
+
extension = File.extname(path).downcase
|
|
19
|
+
return [path] unless EXTENSIONS.include?(extension)
|
|
20
|
+
|
|
21
|
+
marked = !parse(stem(path))[1].negative?
|
|
22
|
+
base = key(stem(path), marked)[0]
|
|
23
|
+
found = siblings(File.dirname(path), extension, base, marked)
|
|
24
|
+
return [path] if found.size < 2 || (!marked && found.none? { |pair| pair[0] == 1 })
|
|
25
|
+
|
|
26
|
+
found.sort_by { |pair| pair[0] }.map { |pair| File.join(File.dirname(path), pair[1]) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The name's words without their markers, joined, and the order the
|
|
30
|
+
# markers give it, -1 for none.
|
|
31
|
+
def self.parse(stem)
|
|
32
|
+
words = words(stem)
|
|
33
|
+
base = []
|
|
34
|
+
values = []
|
|
35
|
+
index = 0
|
|
36
|
+
index = take(words, index, base, values) while index < words.size
|
|
37
|
+
[base.join(" "), order(values)]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The words before a trailing number or letter, joined, and its
|
|
41
|
+
# order, -1 for a name that doesn't end in one.
|
|
42
|
+
def self.trailing(stem)
|
|
43
|
+
words = words(stem)
|
|
44
|
+
last = words.last.to_s
|
|
45
|
+
return ["", -1] if words.size < 2 || !value?(last) || last.length > 2
|
|
46
|
+
|
|
47
|
+
[words[0, words.size - 1].join(" "), value(last)]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def self.words(stem) = stem.downcase.split(/[^a-z0-9]+/).reject(&:empty?)
|
|
51
|
+
|
|
52
|
+
def self.stem(path) = File.basename(path, File.extname(path))
|
|
53
|
+
|
|
54
|
+
def self.key(stem, marked) = marked ? parse(stem) : trailing(stem)
|
|
55
|
+
|
|
56
|
+
# The order and name of each image in the folder in the set.
|
|
57
|
+
def self.siblings(directory, extension, base, marked)
|
|
58
|
+
found = []
|
|
59
|
+
children(directory).each do |name|
|
|
60
|
+
next unless File.extname(name).downcase == extension
|
|
61
|
+
|
|
62
|
+
name_key = key(stem(name), marked)
|
|
63
|
+
found << [name_key[1], name] if name_key[0] == base && !name_key[1].negative?
|
|
64
|
+
end
|
|
65
|
+
found
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Adds the word at `index` to the base, or its marker's value to the
|
|
69
|
+
# values, and returns the index of the next word.
|
|
70
|
+
def self.take(words, index, base, values)
|
|
71
|
+
word = words[index]
|
|
72
|
+
following = words[index + 1].to_s
|
|
73
|
+
if KINDS.include?(word) && value?(following)
|
|
74
|
+
values << value(following)
|
|
75
|
+
index += 2
|
|
76
|
+
index += 2 if words[index] == "of" && words[index + 1].to_s.match?(/\A\d+\z/)
|
|
77
|
+
index
|
|
78
|
+
elsif marker?(word)
|
|
79
|
+
values << value(word.sub(/\A(disk|disc|side|part|d|s)/, ""))
|
|
80
|
+
index + 1
|
|
81
|
+
else
|
|
82
|
+
base << word
|
|
83
|
+
index + 1
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def self.marker?(word) = word.match?(/\A(disk|disc|side|part)(\d+|[a-z])\z/) || word.match?(/\A[ds]\d+\z/)
|
|
88
|
+
|
|
89
|
+
def self.value?(word) = word.match?(/\A(\d+|[a-z])\z/)
|
|
90
|
+
|
|
91
|
+
# A number counts as itself and a letter by its place in the
|
|
92
|
+
# alphabet, so side A is 1.
|
|
93
|
+
def self.value(word) = word.match?(/\A\d+\z/) ? word.to_i : word.ord - 96
|
|
94
|
+
|
|
95
|
+
def self.order(values)
|
|
96
|
+
return -1 if values.empty?
|
|
97
|
+
|
|
98
|
+
values.reduce(0) { |order, value| (order * 1000) + value }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def self.children(directory)
|
|
102
|
+
Dir.children(directory)
|
|
103
|
+
rescue SystemCallError
|
|
104
|
+
[]
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Media
|
|
5
|
+
# An ordered list of media files to go through one at a time, such as
|
|
6
|
+
# the tunes of a player. Each entry is a file and the part of it to
|
|
7
|
+
# start on, counting from 1, out of the parts the file holds: a .sid
|
|
8
|
+
# tune's subtunes, say, or 1 of 1 for media with no parts.
|
|
9
|
+
#
|
|
10
|
+
# #next_entry and #previous_entry step through the entries, and
|
|
11
|
+
# #next_part and #previous_part through the current entry's parts,
|
|
12
|
+
# stopping at its ends. Each returns the entry it moved to, or nil
|
|
13
|
+
# where it stays put. With #loop? the entry steps wrap around at
|
|
14
|
+
# either end, and with #shuffle? they follow a shuffled order that
|
|
15
|
+
# starts from the current entry. #all_parts? decides where #advance
|
|
16
|
+
# goes once a part has played out: on through the entry's parts, or
|
|
17
|
+
# to the next entry. An entry stepped to starts on its first part
|
|
18
|
+
# with #all_parts?, otherwise on its own.
|
|
19
|
+
#
|
|
20
|
+
# `shuffle`, if given, returns the entry indices it's given in a new
|
|
21
|
+
# order in place of Array#shuffle. `build`, if given, turns the paths
|
|
22
|
+
# #add is given into entries.
|
|
23
|
+
class Queue
|
|
24
|
+
# A file and the part of it to start on.
|
|
25
|
+
class Entry
|
|
26
|
+
attr_reader :path, :part, :parts
|
|
27
|
+
|
|
28
|
+
def initialize(path, part: 1, parts: 1)
|
|
29
|
+
@path = path
|
|
30
|
+
@part = part
|
|
31
|
+
@parts = parts
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :part
|
|
36
|
+
|
|
37
|
+
# The files `paths` name, in order: a file as given, and a directory
|
|
38
|
+
# as every file below it with the extension, in path order.
|
|
39
|
+
def self.files(paths, extension)
|
|
40
|
+
paths.flat_map { |path| File.directory?(path) ? below(path, extension).sort : [path] }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.below(directory, extension)
|
|
44
|
+
paths = []
|
|
45
|
+
Dir.children(directory).each do |name|
|
|
46
|
+
path = File.join(directory, name)
|
|
47
|
+
if File.directory?(path)
|
|
48
|
+
paths.concat(below(path, extension))
|
|
49
|
+
elsif File.extname(name).casecmp?(extension)
|
|
50
|
+
paths << path
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
paths
|
|
54
|
+
end
|
|
55
|
+
private_class_method :below
|
|
56
|
+
|
|
57
|
+
def initialize(entries, all_parts: false, shuffle: nil, build: nil)
|
|
58
|
+
@entries = entries
|
|
59
|
+
@shuffler = shuffle
|
|
60
|
+
@builder = build
|
|
61
|
+
@order = (0...entries.size).to_a
|
|
62
|
+
@position = 0
|
|
63
|
+
@part = entries.empty? ? 0 : entries.first.part
|
|
64
|
+
@all_parts = all_parts
|
|
65
|
+
@shuffle = false
|
|
66
|
+
@loop = false
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def size = @entries.size
|
|
70
|
+
|
|
71
|
+
def empty? = @entries.empty?
|
|
72
|
+
|
|
73
|
+
# Where the current entry stands in the order played, from 1.
|
|
74
|
+
def position = empty? ? 0 : @position + 1
|
|
75
|
+
|
|
76
|
+
def entry = empty? ? nil : @entries[@order[@position]]
|
|
77
|
+
|
|
78
|
+
def all_parts? = @all_parts
|
|
79
|
+
|
|
80
|
+
def shuffle? = @shuffle
|
|
81
|
+
|
|
82
|
+
def loop? = @loop
|
|
83
|
+
|
|
84
|
+
# Puts the entries `build` makes of the paths at the end of the order,
|
|
85
|
+
# shuffled or not. An empty queue starts on the first of them.
|
|
86
|
+
def add(paths)
|
|
87
|
+
return if @builder.nil?
|
|
88
|
+
|
|
89
|
+
started = empty?
|
|
90
|
+
first = @entries.size
|
|
91
|
+
@entries.concat(@builder.call(paths))
|
|
92
|
+
@order.concat((first...@entries.size).to_a)
|
|
93
|
+
@part = entry.part if started && !empty?
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def toggle_all_parts = @all_parts = !@all_parts
|
|
97
|
+
|
|
98
|
+
def toggle_loop = @loop = !@loop
|
|
99
|
+
|
|
100
|
+
def toggle_shuffle
|
|
101
|
+
return @shuffle = !@shuffle if empty?
|
|
102
|
+
|
|
103
|
+
current = @order[@position]
|
|
104
|
+
@shuffle = !@shuffle
|
|
105
|
+
@order = (0...size).to_a
|
|
106
|
+
@order = [current] + shuffled(@order - [current]) if @shuffle
|
|
107
|
+
@position = @order.index(current)
|
|
108
|
+
@shuffle
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def next_entry
|
|
112
|
+
return if empty?
|
|
113
|
+
|
|
114
|
+
move(@position + 1)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def previous_entry
|
|
118
|
+
return if empty?
|
|
119
|
+
|
|
120
|
+
move(@position - 1)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def next_part
|
|
124
|
+
return if empty? || @part >= entry.parts
|
|
125
|
+
|
|
126
|
+
@part += 1
|
|
127
|
+
entry
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def previous_part
|
|
131
|
+
return if empty? || @part <= 1
|
|
132
|
+
|
|
133
|
+
@part -= 1
|
|
134
|
+
entry
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Where to go once the current part has played to its end: the
|
|
138
|
+
# entry's next part with #all_parts?, otherwise the next entry.
|
|
139
|
+
def advance
|
|
140
|
+
return next_part if @all_parts && !empty? && @part < entry.parts
|
|
141
|
+
|
|
142
|
+
next_entry
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
private
|
|
146
|
+
|
|
147
|
+
def move(position)
|
|
148
|
+
return unless position.between?(0, size - 1) || @loop
|
|
149
|
+
|
|
150
|
+
@position = position % size
|
|
151
|
+
@part = @all_parts ? 1 : entry.part
|
|
152
|
+
entry
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def shuffled(indices) = @shuffler ? @shuffler.call(indices) : indices.shuffle
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
data/lib/badline/media.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "badline/media/true_drive"
|
|
4
|
+
require "badline/media/queue"
|
|
5
|
+
require "badline/media/disk_set"
|
|
4
6
|
|
|
5
7
|
module Badline
|
|
6
8
|
module Media
|
|
@@ -25,7 +27,7 @@ module Badline
|
|
|
25
27
|
# A .g64 plugs in a true drive as device 8 (TrueDrive), and with one
|
|
26
28
|
# there, a .d64 goes into it instead of the KERNAL traps too. The
|
|
27
29
|
# autostart then loads through it.
|
|
28
|
-
def attach(computer, path, autostart: true,
|
|
30
|
+
def attach(computer, path, autostart: true, subtune: nil, **options)
|
|
29
31
|
if TrueDrive.takes?(computer, path)
|
|
30
32
|
attach_true_drive(computer, path, options.fetch(:disk, {}), autostart:)
|
|
31
33
|
elsif File.directory?(path)
|
|
@@ -34,7 +36,7 @@ module Badline
|
|
|
34
36
|
elsif File.extname(path).downcase == ".crt"
|
|
35
37
|
attach_cartridge(computer, path, options.fetch(:cartridge, {}))
|
|
36
38
|
elsif File.extname(path).downcase == ".sid"
|
|
37
|
-
attach_sid(computer, path, autostart:,
|
|
39
|
+
attach_sid(computer, path, autostart:, subtune:)
|
|
38
40
|
elsif File.extname(path).downcase == ".tap"
|
|
39
41
|
attach_tape(computer, path, autostart:)
|
|
40
42
|
elsif MOUNT_TYPES.key?(File.extname(path).downcase)
|
|
@@ -91,18 +93,22 @@ module Badline
|
|
|
91
93
|
"Attached cartridge #{path}"
|
|
92
94
|
end
|
|
93
95
|
|
|
94
|
-
def attach_sid(computer, path, autostart:,
|
|
96
|
+
def attach_sid(computer, path, autostart:, subtune:)
|
|
95
97
|
tune = Storage::SIDFile.new(path)
|
|
96
|
-
|
|
97
|
-
|
|
98
|
+
if tune.sids > 1
|
|
99
|
+
raise Storage::SIDFile::FormatError, "Written for #{tune.sids} SIDs, which only the SID player plays"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
subtune = (subtune || tune.start_subtune).clamp(1, tune.subtunes)
|
|
103
|
+
computer.on_init { start_tune(computer, tune, autostart:, subtune:) }
|
|
98
104
|
title = tune.name.empty? ? path : tune.name
|
|
99
|
-
title += " (
|
|
105
|
+
title += " (subtune #{subtune})" if tune.subtunes > 1
|
|
100
106
|
autostart ? "Playing #{title}" : "Loaded #{title}"
|
|
101
107
|
end
|
|
102
108
|
|
|
103
|
-
def start_tune(computer, tune, autostart:,
|
|
109
|
+
def start_tune(computer, tune, autostart:, subtune:)
|
|
104
110
|
computer.ram.write(tune.load_address, tune.data)
|
|
105
|
-
tune.boot_memory(
|
|
111
|
+
tune.boot_memory(subtune:).each { |address, bytes| computer.ram.write(address, bytes) }
|
|
106
112
|
computer.type_text(tune.boot_command) if autostart
|
|
107
113
|
end
|
|
108
114
|
|