badline 0.6.1 → 0.7.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 +37 -0
- data/README.md +125 -46
- 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/frontend/audio_sink.rb +6 -5
- data/lib/badline/frontend/boot.rb +1 -1
- data/lib/badline/frontend/buttons.rb +90 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/painter.rb +175 -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/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +14 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +13 -8
- data/lib/badline/options/help.rb +34 -5
- data/lib/badline/options/option.rb +1 -1
- data/lib/badline/options/table.rb +4 -2
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +62 -43
- 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 +24 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's INFO view: the tune's STIL entry, and the playing
|
|
6
|
+
# subtune's, laid out as STIL.txt lays them out but reflowed to the
|
|
7
|
+
# window's width. A line of STIL's that runs to its width goes on into
|
|
8
|
+
# the next, and a shorter one ends a paragraph. Text longer than the view
|
|
9
|
+
# scrolls, by the wheel, the up and down keys, or a click or drag along
|
|
10
|
+
# the scroll bar beside it.
|
|
11
|
+
class InfoView
|
|
12
|
+
HEIGHT = VisualizerView::HEIGHT
|
|
13
|
+
LINE = 10
|
|
14
|
+
ROWS = HEIGHT / LINE
|
|
15
|
+
LEFT = SIDView::LEFT
|
|
16
|
+
BAR = 6
|
|
17
|
+
BAR_LEFT = PlayerHeader::WIDTH - 16 - BAR
|
|
18
|
+
COLUMNS = (BAR_LEFT - LEFT - 8) / Painter::GLYPH
|
|
19
|
+
LABEL = 9
|
|
20
|
+
|
|
21
|
+
# How long a line of STIL's is when it runs on into the next.
|
|
22
|
+
FULL = 60
|
|
23
|
+
|
|
24
|
+
attr_reader :offset
|
|
25
|
+
|
|
26
|
+
def initialize(painter, buttons, top)
|
|
27
|
+
@painter = painter
|
|
28
|
+
@buttons = buttons
|
|
29
|
+
@top = top
|
|
30
|
+
@lines = []
|
|
31
|
+
@offset = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Shows the tune's fields, then the subtune's under its number, from
|
|
35
|
+
# the top.
|
|
36
|
+
def show(fields, subtune, subtune_fields)
|
|
37
|
+
lines = InfoView.layout(fields)
|
|
38
|
+
lines += ["", "(##{subtune})"] + InfoView.layout(subtune_fields) unless subtune_fields.empty?
|
|
39
|
+
@lines = lines.empty? ? ["No STIL entry for this tune."] : lines
|
|
40
|
+
@offset = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def scroll(rows)
|
|
44
|
+
@offset = (@offset + rows).clamp(0, last_offset)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Scrolls to the place `top` points at along the scroll bar.
|
|
48
|
+
def scroll_along(top)
|
|
49
|
+
@offset = ((top - @top).to_f / HEIGHT * @lines.size).round.clamp(0, last_offset)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def draw
|
|
53
|
+
index = 0
|
|
54
|
+
while index < ROWS && @offset + index < @lines.size
|
|
55
|
+
@painter.text(LEFT, @top + (index * LINE), @lines[@offset + index], SIDView::TEXT)
|
|
56
|
+
index += 1
|
|
57
|
+
end
|
|
58
|
+
draw_bar if @lines.size > ROWS
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Each field's label, right-aligned as in STIL.txt, before its text
|
|
62
|
+
# reflowed into the columns beside it.
|
|
63
|
+
def self.layout(fields)
|
|
64
|
+
lines = []
|
|
65
|
+
fields.each do |field|
|
|
66
|
+
label = "#{field.name}:".rjust(LABEL - 1)
|
|
67
|
+
paragraphs(field.text).each do |paragraph|
|
|
68
|
+
wrap(paragraph, COLUMNS - LABEL).each do |line|
|
|
69
|
+
lines << "#{label} #{line}"
|
|
70
|
+
label = " " * (LABEL - 1)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
lines
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The text's lines joined where one ran to STIL's width.
|
|
78
|
+
def self.paragraphs(text)
|
|
79
|
+
paragraphs = []
|
|
80
|
+
full = false
|
|
81
|
+
text.split("\n").each do |line|
|
|
82
|
+
if full
|
|
83
|
+
paragraphs[-1] = "#{paragraphs[-1]} #{line.strip}"
|
|
84
|
+
else
|
|
85
|
+
paragraphs << line.strip
|
|
86
|
+
end
|
|
87
|
+
full = line.length >= FULL
|
|
88
|
+
end
|
|
89
|
+
paragraphs
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# The text broken at spaces into lines of at most `columns`, and a word
|
|
93
|
+
# longer than that broken where it has to be.
|
|
94
|
+
def self.wrap(text, columns)
|
|
95
|
+
lines = []
|
|
96
|
+
rest = text
|
|
97
|
+
while rest.length > columns
|
|
98
|
+
cut = rest.rindex(" ", columns)
|
|
99
|
+
cut = columns if cut.nil? || cut.zero?
|
|
100
|
+
lines << rest[0, cut].rstrip
|
|
101
|
+
rest = rest[cut, rest.length - cut].lstrip
|
|
102
|
+
end
|
|
103
|
+
lines << rest
|
|
104
|
+
lines
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def last_offset = [@lines.size - ROWS, 0].max
|
|
110
|
+
|
|
111
|
+
def draw_bar
|
|
112
|
+
@buttons.area([BAR_LEFT, @top, BAR, HEIGHT], :scroll)
|
|
113
|
+
@painter.box(BAR_LEFT, @top, BAR, HEIGHT, SIDView::BOX)
|
|
114
|
+
thumb = [HEIGHT * ROWS / @lines.size, 8].max
|
|
115
|
+
place = (HEIGHT - thumb) * @offset / [last_offset, 1].max
|
|
116
|
+
@painter.box(BAR_LEFT, @top + place, BAR, thumb, SIDView::TEXT)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Draws on an SDL renderer in the C64's colours and its character ROM's
|
|
6
|
+
# font: filled boxes, lines and text. The font is the ROM's second set,
|
|
7
|
+
# with both cases, turned into a texture once: 16 by 16 glyphs of 8 by 8
|
|
8
|
+
# pixels, white where the glyph is set and clear elsewhere, tinted to
|
|
9
|
+
# the colour asked for as each glyph is drawn.
|
|
10
|
+
class Painter
|
|
11
|
+
GLYPH = 8
|
|
12
|
+
ATLAS = GLYPH * 16
|
|
13
|
+
|
|
14
|
+
# An opaque white pixel, in the low half of a word and in the high
|
|
15
|
+
# half, where it only fits a signed 64-bit word as a negative number.
|
|
16
|
+
SET = 0xffffffff
|
|
17
|
+
SET_HIGH = -0x1_0000_0000
|
|
18
|
+
|
|
19
|
+
SPACE = 0x20
|
|
20
|
+
|
|
21
|
+
# Icons drawn in the C64's 8 by 8 cells, which take the places of the
|
|
22
|
+
# first reversed glyphs, from ICON on.
|
|
23
|
+
ICON = 0x80
|
|
24
|
+
ICONS = {
|
|
25
|
+
play: %w[.X...... .XX..... .XXX.... .XXXX... .XXXX... .XXX.... .XX..... .X......],
|
|
26
|
+
pause: %w[........ .XX..XX. .XX..XX. .XX..XX. .XX..XX. .XX..XX. .XX..XX. ........],
|
|
27
|
+
previous: %w[X.....X. X....XX. X...XXX. X..XXXX. X..XXXX. X...XXX. X....XX. X.....X.],
|
|
28
|
+
next: %w[.X.....X .XX....X .XXX...X .XXXX..X .XXXX..X .XXX...X .XX....X .X.....X],
|
|
29
|
+
left: %w[........ ....X... ...XX... ..XXX... ..XXX... ...XX... ....X... ........],
|
|
30
|
+
right: %w[........ ...X.... ...XX... ...XXX.. ...XXX.. ...XX... ...X.... ........]
|
|
31
|
+
}.freeze
|
|
32
|
+
ICON_NAMES = ICONS.keys.freeze
|
|
33
|
+
|
|
34
|
+
# The screen code of each printable ASCII character in the second set.
|
|
35
|
+
# The rest show as a question mark.
|
|
36
|
+
SCREEN_CODES = Array.new(128) do |ascii|
|
|
37
|
+
if ascii.between?(0x20, 0x3f) || ascii.between?(0x41, 0x5a) then ascii
|
|
38
|
+
elsif ascii == 0x40 then 0
|
|
39
|
+
elsif ascii.between?(0x61, 0x7a) then ascii - 0x60
|
|
40
|
+
elsif ascii == 0x5b then 27
|
|
41
|
+
elsif ascii == 0x5d then 29
|
|
42
|
+
elsif ascii == 0x5e then 30
|
|
43
|
+
elsif ascii == 0x5f then 100
|
|
44
|
+
else 63
|
|
45
|
+
end
|
|
46
|
+
end.freeze
|
|
47
|
+
|
|
48
|
+
def initialize(renderer)
|
|
49
|
+
@renderer = renderer
|
|
50
|
+
@font = SDL.SDL_CreateTexture(renderer, SDL::PIXELFORMAT_ARGB8888, SDL::TEXTUREACCESS_STREAMING, ATLAS, ATLAS)
|
|
51
|
+
SDL.SDL_SetTextureBlendMode(@font, SDL::BLENDMODE_BLEND)
|
|
52
|
+
load_font
|
|
53
|
+
SDL.rect_w(SDL.glyph_rect, GLYPH)
|
|
54
|
+
SDL.rect_h(SDL.glyph_rect, GLYPH)
|
|
55
|
+
@tint = -1
|
|
56
|
+
@size = 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def close = SDL.SDL_DestroyTexture(@font)
|
|
60
|
+
|
|
61
|
+
def box(left, top, width, height, rgb)
|
|
62
|
+
@size = 0
|
|
63
|
+
rect = SDL.rect
|
|
64
|
+
SDL.rect_x(rect, left)
|
|
65
|
+
SDL.rect_y(rect, top)
|
|
66
|
+
SDL.rect_w(rect, width)
|
|
67
|
+
SDL.rect_h(rect, height)
|
|
68
|
+
pen(rgb)
|
|
69
|
+
SDL.SDL_RenderFillRect(@renderer, rect)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Joins the points, each made by Painter.point, with lines.
|
|
73
|
+
def polyline(points, rgb)
|
|
74
|
+
pen(rgb)
|
|
75
|
+
SDL.SDL_RenderDrawLines(@renderer, points, points.size)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# A point for #polyline: an SDL_Point, two 32-bit ints packed into one
|
|
79
|
+
# of the words an Integer Array hands to C, x in the low half.
|
|
80
|
+
def self.point(left, top) = left | (top << 32)
|
|
81
|
+
|
|
82
|
+
# Draws the text with its top left at `left` and `top`, each glyph
|
|
83
|
+
# `scale` times its size, and returns the width drawn.
|
|
84
|
+
def text(left, top, string, rgb, scale: 1)
|
|
85
|
+
tint(rgb)
|
|
86
|
+
place(top, GLYPH * scale)
|
|
87
|
+
x = left
|
|
88
|
+
string.each_char do |char|
|
|
89
|
+
code = screen_code(char)
|
|
90
|
+
glyph(code, x) unless code == SPACE
|
|
91
|
+
x += @size
|
|
92
|
+
end
|
|
93
|
+
x - left
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.width(string, scale: 1) = string.length * GLYPH * scale
|
|
97
|
+
|
|
98
|
+
def icon(name, left, top, rgb, scale: 1)
|
|
99
|
+
tint(rgb)
|
|
100
|
+
place(top, GLYPH * scale)
|
|
101
|
+
glyph(ICON + ICON_NAMES.index(name), left)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def screen_code(char)
|
|
107
|
+
ascii = char.ord
|
|
108
|
+
ascii < 128 ? SCREEN_CODES[ascii] : 63
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Sets the row and size glyphs go at, which a run of text shares.
|
|
112
|
+
def place(top, size)
|
|
113
|
+
to = SDL.rect
|
|
114
|
+
SDL.rect_y(to, top)
|
|
115
|
+
return if size == @size
|
|
116
|
+
|
|
117
|
+
@size = size
|
|
118
|
+
SDL.rect_w(to, size)
|
|
119
|
+
SDL.rect_h(to, size)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def glyph(code, left)
|
|
123
|
+
from = SDL.glyph_rect
|
|
124
|
+
SDL.rect_x(from, (code % 16) * GLYPH)
|
|
125
|
+
SDL.rect_y(from, (code / 16) * GLYPH)
|
|
126
|
+
to = SDL.rect
|
|
127
|
+
SDL.rect_x(to, left)
|
|
128
|
+
SDL.SDL_RenderCopy(@renderer, @font, from, to)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def pen(rgb)
|
|
132
|
+
SDL.SDL_SetRenderDrawColor(@renderer, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff, 255)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def tint(rgb)
|
|
136
|
+
return if rgb == @tint
|
|
137
|
+
|
|
138
|
+
@tint = rgb
|
|
139
|
+
SDL.SDL_SetTextureColorMod(@font, (rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Two pixels to a word, the left one in the low half, as Screen packs
|
|
143
|
+
# them.
|
|
144
|
+
def load_font
|
|
145
|
+
rom = ROM.read("character.rom")
|
|
146
|
+
words = Array.new(ATLAS * ATLAS / 2, 0)
|
|
147
|
+
256.times { |code| unpack_glyph(rom[0x800 + (code * GLYPH), GLYPH], code, words) }
|
|
148
|
+
ICON_NAMES.each_with_index { |name, index| unpack_glyph(icon_rows(name), ICON + index, words) }
|
|
149
|
+
rect = SDL.glyph_rect
|
|
150
|
+
SDL.rect_x(rect, 0)
|
|
151
|
+
SDL.rect_y(rect, 0)
|
|
152
|
+
SDL.rect_w(rect, ATLAS)
|
|
153
|
+
SDL.rect_h(rect, ATLAS)
|
|
154
|
+
SDL.SDL_UpdateTexture(@font, rect, words, ATLAS * 4)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def icon_rows(name)
|
|
158
|
+
ICONS.fetch(name).map { |row| row.tr(".X", "01").to_i(2) }
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def unpack_glyph(rows, code, words)
|
|
162
|
+
GLYPH.times do |row|
|
|
163
|
+
bits = rows[row]
|
|
164
|
+
y = ((code / 16) * GLYPH) + row
|
|
165
|
+
x = (code % 16) * GLYPH
|
|
166
|
+
4.times do |pair|
|
|
167
|
+
left = bits[7 - (pair * 2)] == 1 ? SET : 0
|
|
168
|
+
right = bits[6 - (pair * 2)] == 1 ? SET_HIGH : 0
|
|
169
|
+
words[(((y * ATLAS) + x) / 2) + pair] = left | right
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# What the SID player's window does with an action a key or a click asks
|
|
6
|
+
# for: one that picks or steps through the views or the SID models, or
|
|
7
|
+
# picks the SID shown, changes the PlayerState, and any other goes on to
|
|
8
|
+
# the jukebox.
|
|
9
|
+
class PlayerCommands
|
|
10
|
+
VIEWS = PlayerHeader::VIEWS
|
|
11
|
+
CHIPS = PlayerHeader::CHIPS
|
|
12
|
+
SIDS = SIDView::SIDS
|
|
13
|
+
|
|
14
|
+
def initialize(state)
|
|
15
|
+
@state = state
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Returns the action for the jukebox, or nil when the window has
|
|
19
|
+
# handled it.
|
|
20
|
+
def handle(action)
|
|
21
|
+
state = @state
|
|
22
|
+
if action == :view then state.view = (state.view + 1) % VIEWS.size
|
|
23
|
+
elsif action == :chip then state.chip = (state.chip + 1) % CHIPS.size
|
|
24
|
+
elsif VIEWS.include?(action) then state.view = VIEWS.index(action)
|
|
25
|
+
elsif CHIPS.include?(action) then state.chip = CHIPS.index(action)
|
|
26
|
+
elsif SIDS.include?(action) then state.sid = SIDS.index(action)
|
|
27
|
+
else return action
|
|
28
|
+
end
|
|
29
|
+
nil
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The bottom of the SID player's window: how far the subtune has played,
|
|
6
|
+
# on a bar a click seeks along, marked with where a seek is headed, and the buttons that pause and step
|
|
7
|
+
# through the queue's tunes and the tune's subtunes and turn the queue's
|
|
8
|
+
# modes on and off.
|
|
9
|
+
class PlayerFooter
|
|
10
|
+
HEIGHT = 44
|
|
11
|
+
WIDTH = PlayerHeader::WIDTH
|
|
12
|
+
CLOCK = "00:00 / 00:00"
|
|
13
|
+
BAR_LEFT = 16
|
|
14
|
+
BAR_WIDTH = WIDTH - 32 - (CLOCK.length * Painter::GLYPH) - 8
|
|
15
|
+
TOGGLES = ["SHUFFLE", "LOOP", "ALL SUBTUNES"].freeze
|
|
16
|
+
TOGGLE_ACTIONS = %i[shuffle loop all_subtunes].freeze
|
|
17
|
+
TOGGLE_NOTES = ["shuffle", "loop", "all subtunes"].freeze
|
|
18
|
+
|
|
19
|
+
def initialize(painter, buttons)
|
|
20
|
+
@painter = painter
|
|
21
|
+
@buttons = buttons
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def draw(state, top)
|
|
25
|
+
draw_progress(state, top + 4)
|
|
26
|
+
row = top + 18
|
|
27
|
+
x = 12
|
|
28
|
+
x += @buttons.icon(x, row, :previous, :previous, scale: 2) + 4
|
|
29
|
+
x += @buttons.icon(x, row, state.paused? ? :play : :pause, :pause, scale: 2) + 4
|
|
30
|
+
x += @buttons.icon(x, row, :next, :next, scale: 2) + 8
|
|
31
|
+
unless state.tunes.zero?
|
|
32
|
+
x += @painter.text(x, row + 6, "TUNE #{state.tune}/#{state.tunes}", SIDView::BRIGHT) + 24
|
|
33
|
+
draw_subtunes(state, x, row + 4)
|
|
34
|
+
end
|
|
35
|
+
draw_toggles(state, row + 4)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The seconds into the subtune a click `left` along the bar asks for.
|
|
39
|
+
def self.seek(left, length) = ((left - BAR_LEFT).to_f / BAR_WIDTH).clamp(0.0, 1.0) * length
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def draw_subtunes(state, left, row)
|
|
44
|
+
x = left + @painter.text(left, row + 2, "SUBTUNE", SIDView::TEXT) + 4
|
|
45
|
+
x += @buttons.icon(x, row, :left, :previous_subtune)
|
|
46
|
+
x += @painter.text(x + 2, row + 2, "#{state.subtune}/#{state.subtunes}", SIDView::BRIGHT) + 4
|
|
47
|
+
@buttons.icon(x, row, :right, :next_subtune)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def draw_progress(state, top)
|
|
51
|
+
played = state.played
|
|
52
|
+
width = BAR_WIDTH
|
|
53
|
+
@buttons.area([BAR_LEFT, top - 2, width, 12], :seek)
|
|
54
|
+
@painter.box(BAR_LEFT, top + 2, width, 4, SIDView::BOX)
|
|
55
|
+
draw_target(state, top) if state.seeking?
|
|
56
|
+
@painter.box(BAR_LEFT, top + 2, along(state, played), 4, SIDView::BRIGHT)
|
|
57
|
+
@painter.text(WIDTH - 16 - Painter.width(CLOCK), top, "#{clock(played)} / #{clock(state.length)}",
|
|
58
|
+
SIDView::TEXT)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def draw_target(state, top)
|
|
62
|
+
target = along(state, state.target)
|
|
63
|
+
@painter.box(BAR_LEFT, top + 2, target, 4, SIDView::DIM)
|
|
64
|
+
@painter.box(BAR_LEFT + [target, BAR_WIDTH - 1].min, top - 1, 1, 10, SIDView::BRIGHT)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# How far along the bar `seconds` into the subtune is.
|
|
68
|
+
def along(state, seconds)
|
|
69
|
+
length = state.length
|
|
70
|
+
length.positive? ? (BAR_WIDTH * seconds / length).round.clamp(0, BAR_WIDTH) : 0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def draw_toggles(state, row)
|
|
74
|
+
x = PlayerHeader.right_aligned(TOGGLES)
|
|
75
|
+
TOGGLES.each_with_index do |label, toggle|
|
|
76
|
+
x += @buttons.text(x, row, label, TOGGLE_ACTIONS[toggle], on: state.on?(TOGGLE_NOTES[toggle])) + 4
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clock(seconds)
|
|
81
|
+
whole = seconds.floor
|
|
82
|
+
format("%<minutes>d:%<seconds>02d", minutes: whole / 60, seconds: whole % 60)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The top of the SID player's window: the tune's name, author and
|
|
6
|
+
# release, the STIL credit for what the subtune covers at the moment, a
|
|
7
|
+
# warning when there is one, and the buttons for the views and the SID
|
|
8
|
+
# model.
|
|
9
|
+
class PlayerHeader
|
|
10
|
+
WIDTH = 640
|
|
11
|
+
VIEW_NAMES = %w[VISUALIZER SID INFO].freeze
|
|
12
|
+
VIEWS = %i[visualizer sid info].freeze
|
|
13
|
+
CHIP_NAMES = %w[AUTO 6581 8580].freeze
|
|
14
|
+
CHIPS = %i[auto mos6581 mos8580].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(painter, buttons)
|
|
17
|
+
@painter = painter
|
|
18
|
+
@buttons = buttons
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Underlines the models playing while AUTO picks them.
|
|
22
|
+
def draw(state, tune, playing_models, credit)
|
|
23
|
+
unless tune.nil?
|
|
24
|
+
@painter.text(16, 8, fit(tune.name, 26), SIDView::BRIGHT, scale: 2)
|
|
25
|
+
@painter.text(16, 28, fit([tune.author, tune.released].reject(&:empty?).join(" - "), 50), SIDView::TEXT)
|
|
26
|
+
@painter.text(16, 40, fit(credit, 76), SIDView::BRIGHT)
|
|
27
|
+
@painter.text(16, 52, warning(state), PlayerWindow::WARNING)
|
|
28
|
+
end
|
|
29
|
+
draw_views(state)
|
|
30
|
+
draw_chips(state, playing_models)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.right_aligned(labels) = WIDTH - 12 - labels.sum { |label| Painter.width(label) + 8 }
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def warning(state) = state.on?("below real time") ? "Running below real time" : ""
|
|
38
|
+
|
|
39
|
+
def draw_views(state)
|
|
40
|
+
x = PlayerHeader.right_aligned(VIEW_NAMES)
|
|
41
|
+
VIEW_NAMES.each_with_index do |name, view|
|
|
42
|
+
x += @buttons.text(x, 6, name, VIEWS[view], on: view == state.view) + 4
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def draw_chips(state, playing_models)
|
|
47
|
+
x = PlayerHeader.right_aligned(CHIP_NAMES)
|
|
48
|
+
CHIP_NAMES.each_with_index do |name, chip|
|
|
49
|
+
width = @buttons.text(x, 24, name, CHIPS[chip], on: chip == state.chip)
|
|
50
|
+
if state.chip.zero? && playing_models.include?(CHIPS[chip])
|
|
51
|
+
@painter.box(x + 2, 37, width - 4, 1, SIDView::BRIGHT)
|
|
52
|
+
end
|
|
53
|
+
x += width + 4
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def fit(text, chars) = text.length > chars ? "#{text[0, chars - 2]}.." : text
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SDL window the SID player draws in, and its drawing: the header,
|
|
6
|
+
# the body of the view picked, and the footer, at the height the view
|
|
7
|
+
# needs, in logical pixels scaled up twice.
|
|
8
|
+
class PlayerScreen
|
|
9
|
+
WIDTH = PlayerHeader::WIDTH
|
|
10
|
+
HEADER = 64
|
|
11
|
+
SCALE = 2
|
|
12
|
+
TITLE = "Badline"
|
|
13
|
+
BACKGROUND = Screen::COLORS[6]
|
|
14
|
+
|
|
15
|
+
attr_reader :buttons, :info
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@window = nil
|
|
19
|
+
@renderer = nil
|
|
20
|
+
@painter = nil
|
|
21
|
+
@buttons = nil
|
|
22
|
+
@info = nil
|
|
23
|
+
@sids = 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def open?
|
|
27
|
+
!@window.nil?
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def open(view)
|
|
31
|
+
SDL.SDL_InitSubSystem(SDL::INIT_VIDEO | SDL::INIT_EVENTS)
|
|
32
|
+
SDL.SDL_SetHint("SDL_RENDER_SCALE_QUALITY", "0")
|
|
33
|
+
@window = SDL.SDL_CreateWindow(TITLE, SDL::WINDOWPOS_CENTERED, SDL::WINDOWPOS_CENTERED,
|
|
34
|
+
WIDTH * SCALE, height(view) * SCALE, SDL::WINDOW_RESIZABLE)
|
|
35
|
+
@renderer = SDL.SDL_CreateRenderer(@window, -1, SDL::RENDERER_ACCELERATED)
|
|
36
|
+
SDL.SDL_RenderSetLogicalSize(@renderer, WIDTH, height(view))
|
|
37
|
+
build
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def close
|
|
41
|
+
return unless open?
|
|
42
|
+
|
|
43
|
+
@painter.close
|
|
44
|
+
SDL.SDL_DestroyRenderer(@renderer)
|
|
45
|
+
SDL.SDL_DestroyWindow(@window)
|
|
46
|
+
SDL.SDL_QuitSubSystem(SDL::INIT_VIDEO | SDL::INIT_EVENTS)
|
|
47
|
+
@window = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Fits the window to the view's height.
|
|
51
|
+
def resize(view)
|
|
52
|
+
SDL.SDL_SetWindowSize(@window, WIDTH * SCALE, height(view) * SCALE)
|
|
53
|
+
SDL.SDL_RenderSetLogicalSize(@renderer, WIDTH, height(view))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Fits the window to a tune on `sids` SIDs, whose SID view is taller
|
|
57
|
+
# than one on a single SID.
|
|
58
|
+
def fit_sids(view, sids)
|
|
59
|
+
return if sids == @sids
|
|
60
|
+
|
|
61
|
+
@sids = sids
|
|
62
|
+
resize(view) if open?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Names the tune in the window's title.
|
|
66
|
+
def title(name)
|
|
67
|
+
SDL.SDL_SetWindowTitle(@window, name.empty? ? TITLE : "#{name} - #{TITLE}") if open?
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Draws a frame of the player: `player` and `history` are the SIDs',
|
|
71
|
+
# nil before anything plays, and `credit` the header's STIL credit.
|
|
72
|
+
def draw(state, player, history, tune, credit)
|
|
73
|
+
return unless open?
|
|
74
|
+
|
|
75
|
+
SDL.SDL_SetRenderDrawColor(@renderer, (BACKGROUND >> 16) & 0xff, (BACKGROUND >> 8) & 0xff,
|
|
76
|
+
BACKGROUND & 0xff, 255)
|
|
77
|
+
SDL.SDL_RenderClear(@renderer)
|
|
78
|
+
@buttons.forget
|
|
79
|
+
@top.draw(state, tune, player.nil? ? [] : player.stereo.models, credit)
|
|
80
|
+
player.nil? ? draw_empty(state.view) : draw_body(state, player, history)
|
|
81
|
+
@bottom.draw(state, height(state.view) - PlayerFooter::HEIGHT)
|
|
82
|
+
SDL.SDL_RenderPresent(@renderer)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
private
|
|
86
|
+
|
|
87
|
+
def height(view) = HEADER + body_height(view) + PlayerFooter::HEIGHT
|
|
88
|
+
|
|
89
|
+
def body_height(view) = view == 1 ? SIDView.height(@sids) : VisualizerView::HEIGHT
|
|
90
|
+
|
|
91
|
+
def build
|
|
92
|
+
@painter = Painter.new(@renderer)
|
|
93
|
+
@buttons = Buttons.new(@painter)
|
|
94
|
+
@top = PlayerHeader.new(@painter, @buttons)
|
|
95
|
+
@bottom = PlayerFooter.new(@painter, @buttons)
|
|
96
|
+
@visualizer = VisualizerView.new(@painter, HEADER + 4)
|
|
97
|
+
@sid_view = SIDView.new(@painter, @buttons, HEADER + 4)
|
|
98
|
+
@info = InfoView.new(@painter, @buttons, HEADER + 4)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def draw_body(state, player, history)
|
|
102
|
+
played = state.played
|
|
103
|
+
case state.view
|
|
104
|
+
when 0 then @visualizer.draw(history, played, clock_hz: player.clock_hz)
|
|
105
|
+
when 1 then draw_sid(state.sid, player, history, played)
|
|
106
|
+
else @info.draw
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# The SID view of the SID picked, or of the last a tune has.
|
|
111
|
+
def draw_sid(sid, player, history, played)
|
|
112
|
+
models = player.stereo.models
|
|
113
|
+
shown = [sid, models.size - 1].min
|
|
114
|
+
@sid_view.draw(history, played, clock_hz: player.clock_hz, model: models[shown], sid: shown)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def draw_empty(view)
|
|
118
|
+
hint = "Drop .sid files or folders here"
|
|
119
|
+
@painter.text((WIDTH - Painter.width(hint, scale: 2)) / 2, HEADER + (body_height(view) / 2) - 8, hint,
|
|
120
|
+
SIDView::TEXT, scale: 2)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# What the SID player's window shows about playback: the subtune and the
|
|
6
|
+
# tune's place in the queue, the time, the modes the jukebox reports
|
|
7
|
+
# as notes, the view and SID model chosen, which of a tune's SIDs the
|
|
8
|
+
# SID view shows, and where a seek is headed until playback gets there.
|
|
9
|
+
class PlayerState
|
|
10
|
+
attr_accessor :subtune, :subtunes, :tune, :tunes, :length, :notes, :view, :chip, :sid
|
|
11
|
+
attr_reader :target
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@subtune = 0
|
|
15
|
+
@subtunes = 0
|
|
16
|
+
@tune = 0
|
|
17
|
+
@tunes = 0
|
|
18
|
+
@length = 0.0
|
|
19
|
+
@notes = []
|
|
20
|
+
@view = 0
|
|
21
|
+
@chip = 0
|
|
22
|
+
@sid = 0
|
|
23
|
+
@elapsed = 0.0
|
|
24
|
+
@elapsed_at = now
|
|
25
|
+
@target = 0.0
|
|
26
|
+
@below = false
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The seconds heard as the jukebox last reported them.
|
|
30
|
+
def elapsed=(seconds)
|
|
31
|
+
@elapsed = seconds
|
|
32
|
+
@elapsed_at = now
|
|
33
|
+
return unless seeking?
|
|
34
|
+
|
|
35
|
+
@below ||= seconds < @target
|
|
36
|
+
stop_seeking if @below && seconds >= @target
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Notes a seek to `seconds`, which lasts until playback has come up
|
|
40
|
+
# to it from below.
|
|
41
|
+
def seek(seconds)
|
|
42
|
+
@target = seconds
|
|
43
|
+
@below = false
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stop_seeking = @target = 0.0
|
|
47
|
+
|
|
48
|
+
def seeking? = @target.positive?
|
|
49
|
+
|
|
50
|
+
# The seconds heard so far: the last reported, carried on by the
|
|
51
|
+
# clock unless it is paused.
|
|
52
|
+
def played
|
|
53
|
+
return @elapsed if paused?
|
|
54
|
+
|
|
55
|
+
[@elapsed + (now - @elapsed_at), @length].min
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def paused? = on?("paused")
|
|
59
|
+
|
|
60
|
+
def on?(note) = @notes.include?(note)
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|