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,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's buttons: each is drawn where it goes on every frame,
|
|
6
|
+
# and remembers its place, so a click finds the action under it. The
|
|
7
|
+
# one under the pointer lights up, and one that's on is drawn reversed.
|
|
8
|
+
# They take the player's colours unless given others: the text, the text
|
|
9
|
+
# under the pointer, the background and, if any, a fill behind each
|
|
10
|
+
# button.
|
|
11
|
+
class Buttons
|
|
12
|
+
PAD = 2
|
|
13
|
+
HEIGHT = Painter::GLYPH + (PAD * 2)
|
|
14
|
+
|
|
15
|
+
def initialize(painter, colors = [SIDView::TEXT, SIDView::BRIGHT, PlayerScreen::BACKGROUND])
|
|
16
|
+
@painter = painter
|
|
17
|
+
@text = colors[0]
|
|
18
|
+
@bright = colors[1]
|
|
19
|
+
@back = colors[2]
|
|
20
|
+
@fill = colors.size > 3 ? colors[3] : -1
|
|
21
|
+
@lefts = []
|
|
22
|
+
@tops = []
|
|
23
|
+
@widths = []
|
|
24
|
+
@heights = []
|
|
25
|
+
@actions = []
|
|
26
|
+
@focusable = []
|
|
27
|
+
@toggles = {}
|
|
28
|
+
@pointer_x = -1
|
|
29
|
+
@pointer_y = -1
|
|
30
|
+
@focus = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The action of the button the keys have moved to, which lights up as
|
|
34
|
+
# the one under the pointer does, or nil.
|
|
35
|
+
attr_accessor :focus
|
|
36
|
+
|
|
37
|
+
def forget
|
|
38
|
+
@lefts.clear
|
|
39
|
+
@tops.clear
|
|
40
|
+
@widths.clear
|
|
41
|
+
@heights.clear
|
|
42
|
+
@actions.clear
|
|
43
|
+
@focusable.clear
|
|
44
|
+
@toggles.clear
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def point(left, top)
|
|
48
|
+
@pointer_x = left
|
|
49
|
+
@pointer_y = top
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Draws a button labelled `label` with its top left at `left`, `top`,
|
|
53
|
+
# and returns its width.
|
|
54
|
+
def text(left, top, label, action, on: false)
|
|
55
|
+
width = Painter.width(label) + (PAD * 2)
|
|
56
|
+
place(left, top, width, HEIGHT, action)
|
|
57
|
+
@painter.box(left, top, width, HEIGHT, @fill) if !on && @fill >= 0
|
|
58
|
+
@painter.box(left, top, width, HEIGHT, action == @focus ? @bright : @text) if on
|
|
59
|
+
@painter.text(left + PAD, top + PAD, label, on ? @back : color(@actions.size - 1))
|
|
60
|
+
width
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Draws a button as #text does, without the fill.
|
|
64
|
+
def plain(left, top, label, action, on: false)
|
|
65
|
+
fill = @fill
|
|
66
|
+
@fill = -1
|
|
67
|
+
width = text(left, top, label, action, on:)
|
|
68
|
+
@fill = fill
|
|
69
|
+
width
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Draws a button as wide as `box`, its left and top and width, with
|
|
73
|
+
# `label` at its left and `value`, if any, at its right, as a menu's
|
|
74
|
+
# row.
|
|
75
|
+
def row(box, label, action, value = "")
|
|
76
|
+
left = box[0]
|
|
77
|
+
top = box[1]
|
|
78
|
+
width = box[2]
|
|
79
|
+
place(left, top, width, HEIGHT, action)
|
|
80
|
+
@painter.box(left, top, width, HEIGHT, @fill) if @fill >= 0
|
|
81
|
+
color = color(@actions.size - 1)
|
|
82
|
+
@painter.text(left + PAD, top + PAD, label, color)
|
|
83
|
+
@painter.text(left + width - PAD - Painter.width(value), top + PAD, value, color) unless value.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Draws a row as #row does, with its choices at its right, `choices`
|
|
87
|
+
# holding their labels, their actions and the index of the one on.
|
|
88
|
+
# A click picks a choice, and pressing the row, `action`, picks the
|
|
89
|
+
# next, which #resolve gives.
|
|
90
|
+
def toggle(box, label, action, choices)
|
|
91
|
+
row(box, label, action)
|
|
92
|
+
labels = choices[0]
|
|
93
|
+
@toggles[action] = choices[1][(choices[2] + 1) % labels.size]
|
|
94
|
+
right = box[0] + box[2] - PAD
|
|
95
|
+
index = labels.size - 1
|
|
96
|
+
while index >= 0
|
|
97
|
+
width = Painter.width(labels[index]) + (PAD * 2)
|
|
98
|
+
right -= width
|
|
99
|
+
choice(right, box[1], labels[index], choices[1][index], index == choices[2])
|
|
100
|
+
right -= 2
|
|
101
|
+
index -= 1
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Draws an icon button `scale` times the size of a glyph, and returns
|
|
106
|
+
# its width.
|
|
107
|
+
def icon(left, top, name, action, scale: 1)
|
|
108
|
+
size = (Painter::GLYPH * scale) + (PAD * 2)
|
|
109
|
+
place(left, top, size, size, action)
|
|
110
|
+
@painter.icon(name, left + PAD, top + PAD, color(@actions.size - 1), scale:)
|
|
111
|
+
size
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Makes a box drawn elsewhere clickable, such as the bar showing the
|
|
115
|
+
# time played.
|
|
116
|
+
def area(box, action)
|
|
117
|
+
place(box[0], box[1], box[2], box[3], action)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The action of the button at `left`, `top`, or nil: a toggle's
|
|
121
|
+
# choice before the row it sits on.
|
|
122
|
+
def action_at(left, top)
|
|
123
|
+
found = nil
|
|
124
|
+
index = 0
|
|
125
|
+
while index < @actions.size
|
|
126
|
+
if inside?(index, left, top)
|
|
127
|
+
return @actions[index] unless @focusable[index]
|
|
128
|
+
|
|
129
|
+
found ||= @actions[index]
|
|
130
|
+
end
|
|
131
|
+
index += 1
|
|
132
|
+
end
|
|
133
|
+
found
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Moves the focus `delta` buttons on, among those drawn from the
|
|
137
|
+
# `first` on, stopping at either end.
|
|
138
|
+
def shift(delta, first)
|
|
139
|
+
return if @actions.size <= first
|
|
140
|
+
|
|
141
|
+
focusable = []
|
|
142
|
+
(first...@actions.size).each { |index| focusable << @actions[index] if @focusable[index] }
|
|
143
|
+
return if focusable.empty?
|
|
144
|
+
|
|
145
|
+
from = focusable.index(@focus)
|
|
146
|
+
@focus = focusable[from.nil? ? 0 : (from + delta).clamp(0, focusable.size - 1)]
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# The action pressing a button stands for: for a toggle's row, its
|
|
150
|
+
# next choice's.
|
|
151
|
+
def resolve(action) = @toggles.fetch(action, action)
|
|
152
|
+
|
|
153
|
+
# The action of the button drawn `index`th, or nil.
|
|
154
|
+
def action(index) = @actions[index]
|
|
155
|
+
|
|
156
|
+
private
|
|
157
|
+
|
|
158
|
+
def place(left, top, width, height, action)
|
|
159
|
+
@lefts << left
|
|
160
|
+
@tops << top
|
|
161
|
+
@widths << width
|
|
162
|
+
@heights << height
|
|
163
|
+
@actions << action
|
|
164
|
+
@focusable << true
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# A choice of a toggle, which a click picks but the keys pass by.
|
|
168
|
+
def choice(left, top, label, action, on)
|
|
169
|
+
width = Painter.width(label) + (PAD * 2)
|
|
170
|
+
place(left, top, width, HEIGHT, action)
|
|
171
|
+
@focusable[-1] = false
|
|
172
|
+
@painter.box(left, top, width, HEIGHT, @text) if on
|
|
173
|
+
@painter.text(left + PAD, top + PAD, label, on ? @back : color(@actions.size - 1))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def color(index) = inside?(index, @pointer_x, @pointer_y) || @actions[index] == @focus ? @bright : @text
|
|
177
|
+
|
|
178
|
+
def inside?(index, left, top)
|
|
179
|
+
left >= @lefts[index] && left < @lefts[index] + @widths[index] &&
|
|
180
|
+
top >= @tops[index] && top < @tops[index] + @heights[index]
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The pause menu's question before something power cycles the machine:
|
|
6
|
+
# a cartridge going in or out, or Quick open starting a file. CANCEL
|
|
7
|
+
# has the focus, so Return alone changes nothing.
|
|
8
|
+
class Confirmation
|
|
9
|
+
TITLES = { cartridge: "INSERT A CARTRIDGE", remove: "REMOVE THE CARTRIDGE", start: "QUICK OPEN" }.freeze
|
|
10
|
+
WARNINGS = { cartridge: "INSERTING IT POWER CYCLES THE MACHINE.", remove: "REMOVING IT POWER CYCLES THE MACHINE.",
|
|
11
|
+
start: "THIS STARTS A NEW MACHINE." }.freeze
|
|
12
|
+
ANSWERS = { cartridge: "INSERT", remove: "REMOVE", start: "START" }.freeze
|
|
13
|
+
|
|
14
|
+
def initialize(painter, buttons)
|
|
15
|
+
@painter = painter
|
|
16
|
+
@buttons = buttons
|
|
17
|
+
@kind = :start
|
|
18
|
+
@path = ""
|
|
19
|
+
@open = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def open? = @open
|
|
23
|
+
|
|
24
|
+
# Asks about `kind`, :cartridge, :remove or :start, for the file at
|
|
25
|
+
# `path`.
|
|
26
|
+
def ask(kind, path)
|
|
27
|
+
@kind = kind
|
|
28
|
+
@path = path
|
|
29
|
+
@open = true
|
|
30
|
+
@buttons.focus = :cancel
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Puts the question away, and returns its kind and path.
|
|
34
|
+
def close
|
|
35
|
+
@open = false
|
|
36
|
+
[@kind, @path]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def draw(left, top)
|
|
40
|
+
painter = @painter
|
|
41
|
+
painter.text(left, top, TITLES.fetch(@kind), PauseMenu::BRIGHT)
|
|
42
|
+
painter.text(left, top + 20, Painter.fit(File.basename(@path), 40), PauseMenu::TEXT) unless @path.empty?
|
|
43
|
+
painter.text(left, top + 44, WARNINGS.fetch(@kind), PauseMenu::TEXT)
|
|
44
|
+
painter.text(left, top + 56, "ANYTHING NOT SAVED IS LOST.", PauseMenu::TEXT)
|
|
45
|
+
@buttons.row([left, top + 80, MenuPages::ROW_WIDTH], "CANCEL", :cancel)
|
|
46
|
+
@buttons.row([left, top + 80 + Buttons::HEIGHT + 2, MenuPages::ROW_WIDTH], ANSWERS.fetch(@kind), :confirm)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -2,53 +2,72 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Frontend
|
|
5
|
-
# Routes host input to the machine
|
|
6
|
-
# steps through and shift-Tab steps back through:
|
|
5
|
+
# Routes host input to the machine. Two settings decide where it goes:
|
|
7
6
|
#
|
|
8
|
-
# -
|
|
9
|
-
#
|
|
10
|
-
# and left shift the other. The arrows start on joystick 2, and
|
|
11
|
-
# swaps them.
|
|
12
|
-
# -
|
|
13
|
-
#
|
|
14
|
-
# paddles or moves the mouse, and the host's left and right
|
|
15
|
-
# to whichever lines the device puts them on.
|
|
7
|
+
# - The keys: every host key goes to the C64 keyboard, or, as Tab
|
|
8
|
+
# switches, the arrow cluster and space drive one joystick and WASD
|
|
9
|
+
# and left shift the other. The arrows start on joystick 2, and
|
|
10
|
+
# #swap_ports swaps them.
|
|
11
|
+
# - A pot device the pause menu plugs into a control port: a 1351
|
|
12
|
+
# mouse or a pair of paddles, which the host mouse drives. Motion
|
|
13
|
+
# turns the paddles or moves the mouse, and the host's left and right
|
|
14
|
+
# buttons go to whichever lines the device puts them on.
|
|
16
15
|
class Controls
|
|
17
|
-
|
|
18
|
-
TAGS = ["", "
|
|
16
|
+
POTS = %i[none mouse1 mouse2 paddles1 paddles2].freeze
|
|
17
|
+
TAGS = ["", "MOUSE 1", "MOUSE 2", "PADDLE 1", "PADDLE 2"].freeze
|
|
19
18
|
|
|
20
19
|
# SDL mouse button numbers.
|
|
21
20
|
MOUSE_BUTTONS = { 1 => :left, 3 => :right }.freeze
|
|
22
21
|
|
|
23
|
-
|
|
22
|
+
# The pot device plugged in, one of POTS.
|
|
23
|
+
attr_reader :pot, :arrows_port
|
|
24
24
|
|
|
25
25
|
def initialize(computer)
|
|
26
26
|
@computer = computer
|
|
27
|
-
@
|
|
27
|
+
@joystick = false
|
|
28
|
+
@pot = :none
|
|
28
29
|
@arrows_port = 2
|
|
29
30
|
@mouse = Input::Mouse1351.new
|
|
30
31
|
@paddles = Input::Paddles.new
|
|
31
32
|
end
|
|
32
33
|
|
|
33
|
-
# Routes the input to another machine, the
|
|
34
|
-
#
|
|
34
|
+
# Routes the input to another machine, the pot device going into its
|
|
35
|
+
# port.
|
|
35
36
|
def computer=(computer)
|
|
36
37
|
release_all
|
|
37
38
|
@computer = computer
|
|
38
39
|
attach_pot_device
|
|
39
40
|
end
|
|
40
41
|
|
|
41
|
-
def joystick_mode? = @
|
|
42
|
+
def joystick_mode? = @joystick
|
|
43
|
+
|
|
44
|
+
# Sends the keys to the joysticks, or with false to the keyboard.
|
|
45
|
+
def joystick_mode=(joystick)
|
|
46
|
+
@joystick = joystick
|
|
47
|
+
release_all
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def toggle_keys
|
|
51
|
+
self.joystick_mode = !@joystick
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Plugs in one of POTS, in place of the one before.
|
|
55
|
+
def plug(pot)
|
|
56
|
+
@pot = pot
|
|
57
|
+
release_all
|
|
58
|
+
attach_pot_device
|
|
59
|
+
end
|
|
42
60
|
|
|
43
61
|
# Whether the host mouse drives a pot device, and so should be held
|
|
44
62
|
# in relative mode.
|
|
45
|
-
def pot_device? =
|
|
63
|
+
def pot_device? = @pot != :none
|
|
46
64
|
|
|
47
|
-
# The title bar's tag for the
|
|
65
|
+
# The title bar's tag for the settings, empty for the keyboard alone.
|
|
48
66
|
def tag
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
TAGS[
|
|
67
|
+
tags = []
|
|
68
|
+
tags << "JOY #{@arrows_port}" if @joystick
|
|
69
|
+
tags << TAGS[POTS.index(@pot)] if pot_device?
|
|
70
|
+
tags.join(", ")
|
|
52
71
|
end
|
|
53
72
|
|
|
54
73
|
def key(scancode, down)
|
|
@@ -59,13 +78,6 @@ module Badline
|
|
|
59
78
|
end
|
|
60
79
|
end
|
|
61
80
|
|
|
62
|
-
# Steps to the next mode, or back with a negative step.
|
|
63
|
-
def cycle_mode(step)
|
|
64
|
-
@mode = MODES[(MODES.index(@mode) + step) % MODES.size]
|
|
65
|
-
release_all
|
|
66
|
-
attach_pot_device
|
|
67
|
-
end
|
|
68
|
-
|
|
69
81
|
def swap_ports
|
|
70
82
|
@arrows_port = 3 - @arrows_port
|
|
71
83
|
release_all
|
|
@@ -99,9 +111,9 @@ module Badline
|
|
|
99
111
|
end
|
|
100
112
|
end
|
|
101
113
|
|
|
102
|
-
def paddles? =
|
|
114
|
+
def paddles? = POTS.index(@pot) >= 3
|
|
103
115
|
|
|
104
|
-
# A fresh device on
|
|
116
|
+
# A fresh pot device on its port, and none on the other.
|
|
105
117
|
def attach_pot_device
|
|
106
118
|
ports = @computer.control_ports
|
|
107
119
|
ports.device1 = nil
|
|
@@ -133,7 +145,7 @@ module Badline
|
|
|
133
145
|
end
|
|
134
146
|
end
|
|
135
147
|
|
|
136
|
-
def port_one? = %i[mouse1 paddles1].include?(@
|
|
148
|
+
def port_one? = %i[mouse1 paddles1].include?(@pot)
|
|
137
149
|
|
|
138
150
|
def joystick_key(scancode, down)
|
|
139
151
|
direction = Keys.arrows(scancode)
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The pause menu's file browser, which lists a folder's subfolders and
|
|
6
|
+
# the files a device takes, folders first, each by name. The keys move
|
|
7
|
+
# through the list, open a folder or pick a file, go up a folder, and
|
|
8
|
+
# jump to the next name starting with a letter or digit. A click picks
|
|
9
|
+
# the row under it, and the wheel scrolls.
|
|
10
|
+
class FileBrowser
|
|
11
|
+
ROW = 12
|
|
12
|
+
ROWS = 12
|
|
13
|
+
COLUMNS = 40
|
|
14
|
+
|
|
15
|
+
RETURN = 40
|
|
16
|
+
ESCAPE = 41
|
|
17
|
+
BACKSPACE = 42
|
|
18
|
+
HOME = 74
|
|
19
|
+
PAGE_UP = 75
|
|
20
|
+
END_KEY = 77
|
|
21
|
+
PAGE_DOWN = 78
|
|
22
|
+
RIGHT = 79
|
|
23
|
+
LEFT = 80
|
|
24
|
+
DOWN = 81
|
|
25
|
+
UP = 82
|
|
26
|
+
DIGITS = "1234567890"
|
|
27
|
+
|
|
28
|
+
attr_reader :directory
|
|
29
|
+
|
|
30
|
+
def initialize(painter, buttons)
|
|
31
|
+
@painter = painter
|
|
32
|
+
@buttons = buttons
|
|
33
|
+
@open = false
|
|
34
|
+
@directory = Dir.pwd
|
|
35
|
+
@extensions = []
|
|
36
|
+
@names = []
|
|
37
|
+
@folders = 0
|
|
38
|
+
@selected = 0
|
|
39
|
+
@top = 0
|
|
40
|
+
@list_top = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def open? = @open
|
|
44
|
+
|
|
45
|
+
# What a name sorts by: lower case, with its numbers padded, so disk 2
|
|
46
|
+
# comes before disk 10.
|
|
47
|
+
def self.order(name) = name.downcase.gsub(/\d+/) { |digits| digits.rjust(10, "0") }
|
|
48
|
+
|
|
49
|
+
# Lists `directory` for the files with one of `extensions`.
|
|
50
|
+
def open(directory, extensions)
|
|
51
|
+
@open = true
|
|
52
|
+
@extensions = extensions
|
|
53
|
+
enter(directory)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def close
|
|
57
|
+
@open = false
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Handles a key, and returns the path of a file picked, :cancel when
|
|
61
|
+
# Esc leaves the browser, or nil.
|
|
62
|
+
def key(scancode)
|
|
63
|
+
return :cancel if scancode == ESCAPE
|
|
64
|
+
return pick if [RETURN, RIGHT].include?(scancode)
|
|
65
|
+
return up if [LEFT, BACKSPACE].include?(scancode)
|
|
66
|
+
|
|
67
|
+
select(@selected + step(scancode)) unless step(scancode).zero?
|
|
68
|
+
jump(scancode)
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Picks the row at `top`, as Return would.
|
|
73
|
+
def click(top)
|
|
74
|
+
row = (top - @list_top) / ROW
|
|
75
|
+
return nil if row.negative? || @top + row >= @names.size
|
|
76
|
+
|
|
77
|
+
@selected = @top + row
|
|
78
|
+
pick
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def scroll(rows)
|
|
82
|
+
@top = (@top + rows).clamp(0, [@names.size - ROWS, 0].max)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def draw(left, top, width, title)
|
|
86
|
+
@painter.text(left, top, title, PauseMenu::BRIGHT)
|
|
87
|
+
@painter.text(left, top + ROW + 2, shown_directory, PauseMenu::DIM)
|
|
88
|
+
@list_top = top + (ROW * 2) + 8
|
|
89
|
+
@buttons.area([left, @list_top, width, ROW * ROWS], :browse)
|
|
90
|
+
draw_rows(left, width)
|
|
91
|
+
draw_bar(left + width - 2) if @names.size > ROWS
|
|
92
|
+
@painter.text(left, @list_top + (ROW * ROWS) + 6, "RETURN: OPEN LEFT: BACK ESC: CANCEL",
|
|
93
|
+
PauseMenu::DIM)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
# The folder's path, cut at the start to fit.
|
|
99
|
+
def shown_directory
|
|
100
|
+
length = @directory.length
|
|
101
|
+
length > COLUMNS ? "...#{@directory[length - COLUMNS + 3, COLUMNS - 3]}" : @directory
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draw_rows(left, width)
|
|
105
|
+
ROWS.times do |row|
|
|
106
|
+
index = @top + row
|
|
107
|
+
break if index >= @names.size
|
|
108
|
+
|
|
109
|
+
name = label(index)
|
|
110
|
+
top = @list_top + (row * ROW)
|
|
111
|
+
if index == @selected
|
|
112
|
+
@painter.box(left - 2, top - 2, width - 4, ROW, PauseMenu::TEXT)
|
|
113
|
+
@painter.text(left, top, name, PauseMenu::PANEL)
|
|
114
|
+
else
|
|
115
|
+
@painter.text(left, top, name, index < @folders ? PauseMenu::BRIGHT : PauseMenu::TEXT)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def draw_bar(left)
|
|
121
|
+
height = ROW * ROWS
|
|
122
|
+
size = [(height * ROWS) / @names.size, 4].max
|
|
123
|
+
offset = ((height - size) * @top) / [@names.size - ROWS, 1].max
|
|
124
|
+
@painter.box(left, @list_top - 2, 2, height, PauseMenu::EDGE)
|
|
125
|
+
@painter.box(left, @list_top - 2 + offset, 2, size, PauseMenu::TEXT)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def label(index)
|
|
129
|
+
name = @names[index]
|
|
130
|
+
name += "/" if index < @folders
|
|
131
|
+
Painter.fit(name, COLUMNS)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def step(scancode)
|
|
135
|
+
case scancode
|
|
136
|
+
when UP then -1
|
|
137
|
+
when DOWN then 1
|
|
138
|
+
when PAGE_UP then -ROWS
|
|
139
|
+
when PAGE_DOWN then ROWS
|
|
140
|
+
when HOME then -@names.size
|
|
141
|
+
when END_KEY then @names.size
|
|
142
|
+
else 0
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def select(index)
|
|
147
|
+
return if @names.empty?
|
|
148
|
+
|
|
149
|
+
@selected = index.clamp(0, @names.size - 1)
|
|
150
|
+
@top = @selected if @selected < @top
|
|
151
|
+
@top = @selected - ROWS + 1 if @selected >= @top + ROWS
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Selects the next name after the one selected that starts with the
|
|
155
|
+
# key's letter or digit.
|
|
156
|
+
def jump(scancode)
|
|
157
|
+
char = if scancode.between?(4, 29) then (97 + scancode - 4).chr
|
|
158
|
+
elsif scancode.between?(30, 39) then DIGITS[scancode - 30]
|
|
159
|
+
else ""
|
|
160
|
+
end
|
|
161
|
+
return if char.empty? || @names.empty?
|
|
162
|
+
|
|
163
|
+
@names.size.times do |offset|
|
|
164
|
+
index = (@selected + 1 + offset) % @names.size
|
|
165
|
+
next unless @names[index].downcase.start_with?(char)
|
|
166
|
+
|
|
167
|
+
select(index)
|
|
168
|
+
break
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def pick
|
|
173
|
+
return nil if @names.empty?
|
|
174
|
+
|
|
175
|
+
path = File.join(@directory, @names[@selected])
|
|
176
|
+
return path unless @selected < @folders
|
|
177
|
+
|
|
178
|
+
@names[@selected] == ".." ? up : enter(path)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def up
|
|
182
|
+
parent = File.dirname(@directory)
|
|
183
|
+
return nil if parent == @directory
|
|
184
|
+
|
|
185
|
+
from = File.basename(@directory)
|
|
186
|
+
enter(parent)
|
|
187
|
+
index = @names.index(from)
|
|
188
|
+
select(index) unless index.nil?
|
|
189
|
+
nil
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def enter(directory)
|
|
193
|
+
@directory = File.expand_path(directory)
|
|
194
|
+
folders = []
|
|
195
|
+
files = []
|
|
196
|
+
children(@directory).each do |name|
|
|
197
|
+
next if name.start_with?(".")
|
|
198
|
+
|
|
199
|
+
if File.directory?(File.join(@directory, name))
|
|
200
|
+
folders << name
|
|
201
|
+
elsif @extensions.include?(File.extname(name).downcase)
|
|
202
|
+
files << name
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
listing(folders, files)
|
|
206
|
+
nil
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def listing(folders, files)
|
|
210
|
+
folders = folders.sort_by { |name| FileBrowser.order(name) }
|
|
211
|
+
folders.unshift("..") unless File.dirname(@directory) == @directory
|
|
212
|
+
@folders = folders.size
|
|
213
|
+
@names = folders + files.sort_by { |name| FileBrowser.order(name) }
|
|
214
|
+
@selected = 0
|
|
215
|
+
@top = 0
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def children(directory)
|
|
219
|
+
Dir.children(directory)
|
|
220
|
+
rescue SystemCallError
|
|
221
|
+
[]
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Draws the SID's filter as the player shows it: its modes, cutoff,
|
|
6
|
+
# resonance and volume, and its response across the audible range on
|
|
7
|
+
# the chip playing, whose cutoff curve the two models don't share.
|
|
8
|
+
class FilterView
|
|
9
|
+
TEXT = SIDView::TEXT
|
|
10
|
+
BRIGHT = SIDView::BRIGHT
|
|
11
|
+
DIM = SIDView::DIM
|
|
12
|
+
BOX = SIDView::BOX
|
|
13
|
+
|
|
14
|
+
MODES = ["LP", "BP", "HP", "3 OFF"].freeze
|
|
15
|
+
|
|
16
|
+
LEFT = SIDView::LEFT
|
|
17
|
+
CURVE_LEFT = 224
|
|
18
|
+
WIDTH = SIDView::WAVE_WIDTH
|
|
19
|
+
HEIGHT = SIDView::PANEL_HEIGHT
|
|
20
|
+
|
|
21
|
+
# The lowest and highest frequencies on the curve's axis.
|
|
22
|
+
LOW_HZ = 30.0
|
|
23
|
+
HIGH_HZ = 16_000.0
|
|
24
|
+
|
|
25
|
+
def initialize(painter)
|
|
26
|
+
@painter = painter
|
|
27
|
+
@curve = Array.new(WIDTH + 1, 0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def draw(history, frame, top, model)
|
|
31
|
+
cutoff = (history.register(frame, 0x15) & 0x07) | (history.register(frame, 0x16) << 3)
|
|
32
|
+
resonance = history.register(frame, 0x17) >> 4
|
|
33
|
+
mode_volume = history.register(frame, 0x18)
|
|
34
|
+
hertz = SID::Filter::W0.fetch(model)[cutoff] / (2 * Math::PI * SID::Filter::SCALE)
|
|
35
|
+
paint = @painter
|
|
36
|
+
paint.text(LEFT, top, "FILTER", BRIGHT)
|
|
37
|
+
x = LEFT + 64
|
|
38
|
+
MODES.each_with_index do |name, bit|
|
|
39
|
+
x += paint.text(x, top, name, mode_volume[bit + 4] == 1 ? BRIGHT : DIM) + 8
|
|
40
|
+
end
|
|
41
|
+
paint.text(LEFT, top + 16, format("CUTOFF $%<cutoff>03X", cutoff:), TEXT)
|
|
42
|
+
paint.text(LEFT, top + 28, format(" %<hertz>d HZ", hertz: hertz.round), TEXT)
|
|
43
|
+
paint.text(LEFT, top + 40, format("RES %<res>X VOL %<vol>X", res: resonance, vol: mode_volume & 0x0f), TEXT)
|
|
44
|
+
draw_curve(hertz, resonance, mode_volume, top)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The gain for the modes selected on a logarithmic axis, with the
|
|
50
|
+
# cutoff marked.
|
|
51
|
+
def draw_curve(cutoff_hertz, resonance, mode_volume, top)
|
|
52
|
+
paint = @painter
|
|
53
|
+
paint.box(CURVE_LEFT, top, WIDTH, HEIGHT, BOX)
|
|
54
|
+
return paint.text(CURVE_LEFT + 8, top + 8, "NO MODE", DIM) if mode_volume.nobits?(0x70)
|
|
55
|
+
|
|
56
|
+
quality = 0.707 + (resonance / 15.0)
|
|
57
|
+
cutoff = [cutoff_hertz, 1.0].max
|
|
58
|
+
points = @curve
|
|
59
|
+
x = 0
|
|
60
|
+
while x <= WIDTH
|
|
61
|
+
hertz = LOW_HZ * ((HIGH_HZ / LOW_HZ)**(x.to_f / WIDTH))
|
|
62
|
+
decibels = gain(hertz / cutoff, quality, mode_volume).clamp(-36.0, 12.0)
|
|
63
|
+
points[x] = Painter.point(CURVE_LEFT + x, top + 8 + (((12.0 - decibels) * (HEIGHT - 12)) / 48.0).round)
|
|
64
|
+
x += 1
|
|
65
|
+
end
|
|
66
|
+
paint.polyline(points, BRIGHT)
|
|
67
|
+
marker = (WIDTH * Math.log(cutoff / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ)).round
|
|
68
|
+
paint.box(CURVE_LEFT + marker, top, 1, HEIGHT, DIM) if marker.between?(0, WIDTH)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The gain in decibels of a two-pole state-variable filter at `ratio`
|
|
72
|
+
# of its cutoff, summing the low-, band- and high-pass outputs chosen.
|
|
73
|
+
def gain(ratio, quality, mode_volume)
|
|
74
|
+
real = 1.0 - (ratio * ratio)
|
|
75
|
+
imaginary = ratio / quality
|
|
76
|
+
denominator = (real * real) + (imaginary * imaginary)
|
|
77
|
+
out_real = 0.0
|
|
78
|
+
out_imaginary = 0.0
|
|
79
|
+
if mode_volume.anybits?(0x10)
|
|
80
|
+
out_real += real / denominator
|
|
81
|
+
out_imaginary -= imaginary / denominator
|
|
82
|
+
end
|
|
83
|
+
if mode_volume.anybits?(0x20)
|
|
84
|
+
out_real += ratio * imaginary / denominator
|
|
85
|
+
out_imaginary += ratio * real / denominator
|
|
86
|
+
end
|
|
87
|
+
if mode_volume.anybits?(0x40)
|
|
88
|
+
out_real -= ratio * ratio * real / denominator
|
|
89
|
+
out_imaginary += ratio * ratio * imaginary / denominator
|
|
90
|
+
end
|
|
91
|
+
magnitude = Math.sqrt((out_real * out_real) + (out_imaginary * out_imaginary))
|
|
92
|
+
20.0 * Math.log([magnitude, 1.0e-6].max) / Math.log(10)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|