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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +125 -46
  4. data/exe/badline-ruby +9 -1
  5. data/lib/badline/address_bus/extra_sids.rb +30 -0
  6. data/lib/badline/address_bus/sid_slots.rb +42 -0
  7. data/lib/badline/address_bus.rb +5 -0
  8. data/lib/badline/audio/aiff.rb +2 -2
  9. data/lib/badline/audio/bare_player.rb +23 -12
  10. data/lib/badline/audio/cli.rb +153 -58
  11. data/lib/badline/audio/jukebox.rb +121 -36
  12. data/lib/badline/audio/machine_player.rb +25 -10
  13. data/lib/badline/audio/pcm_writer.rb +10 -6
  14. data/lib/badline/audio/playback.rb +6 -5
  15. data/lib/badline/audio/queued_tune.rb +89 -0
  16. data/lib/badline/audio/renderer.rb +57 -10
  17. data/lib/badline/audio/stereo.rb +97 -0
  18. data/lib/badline/audio/terminal.rb +39 -6
  19. data/lib/badline/audio/wav.rb +1 -1
  20. data/lib/badline/audio.rb +2 -0
  21. data/lib/badline/frontend/audio_sink.rb +6 -5
  22. data/lib/badline/frontend/boot.rb +1 -1
  23. data/lib/badline/frontend/buttons.rb +90 -0
  24. data/lib/badline/frontend/filter_view.rb +96 -0
  25. data/lib/badline/frontend/info_view.rb +120 -0
  26. data/lib/badline/frontend/painter.rb +175 -0
  27. data/lib/badline/frontend/player_commands.rb +33 -0
  28. data/lib/badline/frontend/player_footer.rb +86 -0
  29. data/lib/badline/frontend/player_header.rb +60 -0
  30. data/lib/badline/frontend/player_screen.rb +124 -0
  31. data/lib/badline/frontend/player_state.rb +67 -0
  32. data/lib/badline/frontend/player_window.rb +228 -0
  33. data/lib/badline/frontend/scope.rb +42 -0
  34. data/lib/badline/frontend/sid_history.rb +175 -0
  35. data/lib/badline/frontend/sid_view.rb +140 -0
  36. data/lib/badline/frontend/visualizer_view.rb +101 -0
  37. data/lib/badline/frontend.rb +14 -0
  38. data/lib/badline/media/queue.rb +158 -0
  39. data/lib/badline/media.rb +13 -8
  40. data/lib/badline/options/help.rb +34 -5
  41. data/lib/badline/options/option.rb +1 -1
  42. data/lib/badline/options/table.rb +4 -2
  43. data/lib/badline/options/validation.rb +54 -0
  44. data/lib/badline/options.rb +62 -43
  45. data/lib/badline/sdl.rb +16 -6
  46. data/lib/badline/sid/catch_up.rb +11 -1
  47. data/lib/badline/sid/filter.rb +9 -0
  48. data/lib/badline/sid/voice.rb +6 -0
  49. data/lib/badline/sid/waveform.rb +20 -5
  50. data/lib/badline/sid.rb +35 -3
  51. data/lib/badline/storage/hvsc.rb +41 -0
  52. data/lib/badline/storage/sid_file.rb +45 -25
  53. data/lib/badline/storage/song_lengths.rb +6 -29
  54. data/lib/badline/storage/stil/credit.rb +50 -0
  55. data/lib/badline/storage/stil.rb +126 -0
  56. data/lib/badline/storage.rb +8 -0
  57. data/lib/badline/version.rb +1 -1
  58. 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