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,228 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's window, which `sid` plays in, drawn by PlayerScreen:
6
+ # a header with the tune, its STIL credit, the views and the SID model,
7
+ # and a footer with the time and the buttons that step through the
8
+ # queue, framing one of three views: the visualizer, with each voice's
9
+ # note and output and the mix, the SID view, with everything the chip
10
+ # is doing, and the tune's STIL entry. The window's height follows the
11
+ # view, and .sid files and folders dropped on it join the queue.
12
+ #
13
+ # It stands in for the terminal, so Audio::Jukebox drives it the same
14
+ # way: #wait handles the window's clicks, keys and drops between frames
15
+ # and redraws it at the display's pace. The SID model buttons switch
16
+ # the chips playing on the spot, and each tune after it starts on the
17
+ # same choice. AUTO gives each of a tune's SIDs its own model.
18
+ class PlayerWindow < Audio::Terminal
19
+ FRAME = 1.0 / 50
20
+
21
+ MOUSEWHEEL = 0x403
22
+ DROPFILE = 0x1000
23
+ UP = 82
24
+ DOWN = 81
25
+
26
+ WARNING = Screen::COLORS[10]
27
+
28
+ CHIPS = PlayerHeader::CHIPS
29
+
30
+ KEYS = {
31
+ 17 => :next, 19 => :previous,
32
+ 79 => :next_subtune, 80 => :previous_subtune,
33
+ 44 => :pause, 22 => :shuffle, 15 => :loop, 4 => :all_subtunes,
34
+ 20 => :quit, 41 => :quit,
35
+ 54 => :back, 55 => :forward,
36
+ 43 => :view, 6 => :chip
37
+ }.freeze
38
+
39
+ def initialize(input:, output:)
40
+ super
41
+ @screen = PlayerScreen.new
42
+ @player = nil
43
+ @tune_file = nil
44
+ @history = SIDHistory.new(Audio::Renderer::DEFAULT_RATE)
45
+ @state = PlayerState.new
46
+ @commands = PlayerCommands.new(@state)
47
+ @next_draw = 0.0
48
+ @seek_left = 0
49
+ @dropped = []
50
+ @credits = []
51
+ @stil = []
52
+ @stil_subtune = 0
53
+ @subtune_stil = []
54
+ @scrolling = false
55
+ end
56
+
57
+ # The seconds into the subtune the last click on the bar asked for.
58
+ def seek_to = PlayerFooter.seek(@seek_left, @state.length)
59
+
60
+ # The files dropped on the window since it last said :drop.
61
+ def dropped
62
+ paths = @dropped
63
+ @dropped = []
64
+ paths
65
+ end
66
+
67
+ def session
68
+ @screen.open(@state.view)
69
+ @screen.info.show(@stil, @stil_subtune, @subtune_stil)
70
+ yield
71
+ ensure
72
+ @screen.close
73
+ end
74
+
75
+ # Handles the window's clicks, keys and drops and redraws it until
76
+ # `seconds` are up, returning early with those that ask the jukebox
77
+ # for something.
78
+ def wait(seconds)
79
+ actions = poll
80
+ deadline = now + seconds
81
+ loop do
82
+ draw if now >= @next_draw
83
+ left = deadline - now
84
+ break if left <= 0 || !actions.empty?
85
+
86
+ SDL.SDL_Delay([([left, @next_draw - now].min * 1000).floor, 1].max)
87
+ actions.concat(poll)
88
+ end
89
+ actions
90
+ end
91
+
92
+ def header(lines) = lines
93
+
94
+ def announce(lines) = lines
95
+
96
+ def place(tune, tunes)
97
+ @state.stop_seeking unless tune == @state.tune
98
+ @state.tune = tune
99
+ @state.tunes = tunes
100
+ end
101
+
102
+ def status(subtune:, subtunes:, elapsed:, length:, notes: [])
103
+ state = @state
104
+ state.stop_seeking unless subtune == state.subtune
105
+ state.subtune = subtune
106
+ state.subtunes = subtunes
107
+ state.elapsed = elapsed
108
+ state.length = length
109
+ state.notes = notes
110
+ end
111
+
112
+ # Keeps the subtune's STIL credits for the header and the whole entry
113
+ # for the INFO view.
114
+ def stil(fields, subtune, subtune_fields)
115
+ @credits = Storage::STIL::Credit.list(subtune_fields.empty? ? fields : subtune_fields)
116
+ @stil = fields
117
+ @stil_subtune = subtune
118
+ @subtune_stil = subtune_fields
119
+ @screen.info.show(fields, subtune, subtune_fields) if @screen.open?
120
+ end
121
+
122
+ # Follows the renderer's SIDs from here on, on the model chosen.
123
+ def playing(renderer)
124
+ @player = renderer.player
125
+ @tune_file = renderer.tune
126
+ stereo = @player.stereo
127
+ @history = SIDHistory.new(renderer.rate, stereo.sids.size)
128
+ stereo.sids.each(&:record_voices!)
129
+ renderer.observer = ->(samples, rendered) { @history.record(stereo.sids, stereo.outputs, samples, rendered) }
130
+ choose_model
131
+ @screen.fit_sids(@state.view, stereo.sids.size)
132
+ @screen.title(renderer.tune.name)
133
+ end
134
+
135
+ private
136
+
137
+ def poll
138
+ actions = []
139
+ while SDL.SDL_PollEvent(SDL.event) != 0
140
+ case SDL.event_type(SDL.event)
141
+ when SDL::QUIT then actions << :quit
142
+ when SDL::KEYDOWN then key(SDL.event_scancode(SDL.event), actions) if SDL.event_repeat(SDL.event).zero?
143
+ when SDL::MOUSEMOTION then point
144
+ when SDL::MOUSEBUTTONDOWN then click(actions) if SDL.event_button(SDL.event) == 1
145
+ when SDL::MOUSEBUTTONUP then @scrolling = false
146
+ when MOUSEWHEEL then @screen.info.scroll(-3 * SDL.event_x(SDL.event))
147
+ when DROPFILE then drop(actions)
148
+ end
149
+ end
150
+ actions
151
+ end
152
+
153
+ def drop(actions)
154
+ file = SDL.event_file(SDL.event)
155
+ @dropped << LibC.strstr(file, "")
156
+ LibC.free(file)
157
+ actions << :drop unless actions.include?(:drop)
158
+ end
159
+
160
+ def point
161
+ top = SDL.event_y(SDL.event)
162
+ @screen.buttons.point(SDL.event_x(SDL.event), top)
163
+ @screen.info.scroll_along(top) if @scrolling
164
+ end
165
+
166
+ def key(scancode, actions)
167
+ return @screen.info.scroll(scancode == UP ? -1 : 1) if [UP, DOWN].include?(scancode)
168
+
169
+ action = KEYS[scancode]
170
+ command(action, actions) unless action.nil?
171
+ end
172
+
173
+ def click(actions)
174
+ left = SDL.event_x(SDL.event)
175
+ action = @screen.buttons.action_at(left, SDL.event_y(SDL.event))
176
+ return if action.nil?
177
+
178
+ @seek_left = left if action == :seek
179
+ return scroll_along if action == :scroll
180
+
181
+ command(action, actions)
182
+ end
183
+
184
+ # Lets PlayerCommands handle the action, then follows a change of view
185
+ # or SID model it made.
186
+ def command(action, actions)
187
+ view = @state.view
188
+ chip = @state.chip
189
+ passed = @commands.handle(action)
190
+ follow_seek(passed) if %i[seek forward back].include?(passed)
191
+ actions << passed unless passed.nil?
192
+ resize unless view == @state.view
193
+ choose_model unless chip == @state.chip
194
+ end
195
+
196
+ # Notes where a seek is headed, as the jukebox will take it.
197
+ def follow_seek(action)
198
+ state = @state
199
+ step = Audio::Jukebox::SEEK_STEP
200
+ seconds = { seek: seek_to, forward: state.played + step, back: state.played - step }.fetch(action)
201
+ state.seek(seconds.clamp(0.0, [state.length - 1.0, 0.0].max))
202
+ end
203
+
204
+ def scroll_along
205
+ @scrolling = true
206
+ @screen.info.scroll_along(SDL.event_y(SDL.event))
207
+ end
208
+
209
+ def resize
210
+ @screen.resize(@state.view)
211
+ @next_draw = 0.0
212
+ end
213
+
214
+ def choose_model
215
+ return if @player.nil?
216
+
217
+ @player.stereo.refit(CHIPS[@state.chip])
218
+ end
219
+
220
+ def draw
221
+ @next_draw = now + FRAME
222
+ @screen.draw(@state, @player, @history, @tune_file, Storage::STIL::Credit.at(@credits, @state.played))
223
+ end
224
+
225
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
226
+ end
227
+ end
228
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # An oscilloscope in a box: one channel of SIDHistory's samples, as
6
+ # heard, with the trace held still on a rising edge. `box` is its left,
7
+ # top, width and height, `span` how many samples fit its width and
8
+ # `range` the swing that fills its height. The trace is centred on the
9
+ # middle of the samples' range when `centred`, and on zero otherwise.
10
+ class Scope
11
+ def initialize(painter, box, span:, range:, centred: false)
12
+ @painter = painter
13
+ @left = box[0]
14
+ @top = box[1]
15
+ @width = box[2]
16
+ @height = box[3]
17
+ @span = span
18
+ @range = range
19
+ @centred = centred
20
+ @points = Array.new(@width + 1, 0)
21
+ end
22
+
23
+ def draw(history, played, channel, rgb)
24
+ @painter.box(@left, @top, @width, @height, SIDView::BOX)
25
+ span = @span
26
+ range = @range
27
+ start = history.scope_start(played, span, channel)
28
+ middle = @centred ? history.middle(start, span, channel) : 0
29
+ centre = @top + (@height / 2)
30
+ points = @points
31
+ x = 0
32
+ while x <= @width
33
+ value = history.sample_at(start + (x * span / @width), channel) - middle
34
+ top = (centre - (value * (@height - 4) / range)).clamp(@top, @top + @height - 1)
35
+ points[x] = Painter.point(@left + x, top)
36
+ x += 1
37
+ end
38
+ @painter.polyline(points, rgb)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Each SID's state at the end of each frame rendered, and the samples
6
+ # rendered, kept for long enough that the SID player can show what is
7
+ # being heard rather than what was last rendered: rendering runs ahead
8
+ # of the audio device by the queue in between.
9
+ #
10
+ # The samples come in four channels a SID, 0 to 3 for SID 1, 4 to 7 for
11
+ # SID 2 and so on: each voice's own output, when the SID records them,
12
+ # then the SID's own output (#output). The left and right of the mix
13
+ # follow them all (#left and #right).
14
+ class SIDHistory
15
+ FRAMES = 64
16
+ SAMPLES = 16_384
17
+
18
+ # Per frame: the 25 write registers, then each voice's envelope level,
19
+ # then each voice's envelope state as an index into STATES.
20
+ FIELDS = 31
21
+ LEVELS = 25
22
+ PHASES = 28
23
+
24
+ STATES = %i[attack decay_sustain release].freeze
25
+
26
+ # SID 1's own output, which is the mix with only one SID.
27
+ MIX = 3
28
+
29
+ attr_reader :rate, :sid_count
30
+
31
+ def initialize(rate, sid_count = 1)
32
+ @rate = rate
33
+ @sid_count = sid_count
34
+ @channels = (sid_count * 4) + 2
35
+ @times = Array.new(FRAMES, 0.0)
36
+ @states = Array.new(FRAMES * FIELDS * sid_count, 0)
37
+ @frames = 0
38
+ @samples = Array.new(SAMPLES * @channels, 0)
39
+ @written = 0
40
+ @rendered = 0.0
41
+ end
42
+
43
+ # Keeps the frame that ends at `rendered` seconds: the state of each of
44
+ # `sids`, each one's own samples in `outputs`, and the stereo mix in
45
+ # `samples`, the left and right interleaved.
46
+ def record(sids, outputs, samples, rendered)
47
+ slot = @frames % FRAMES
48
+ @times[slot] = rendered
49
+ sids.size.times { |index| keep_state(sids[index], ((slot * @sid_count) + index) * FIELDS) }
50
+ @frames += 1
51
+ keep(sids, outputs, samples)
52
+ @rendered = rendered
53
+ end
54
+
55
+ def empty? = @frames.zero?
56
+
57
+ def output(sid) = (sid * 4) + MIX
58
+
59
+ def left = @sid_count * 4
60
+
61
+ def right = left + 1
62
+
63
+ # Where the fields of SID `sid` in the frame heard at `played` seconds
64
+ # start: the latest frame rendered by then, or the oldest kept.
65
+ def frame(played, sid = 0)
66
+ kept = [@frames, FRAMES].min
67
+ newest = @frames - 1
68
+ age = 0
69
+ while age < kept - 1
70
+ break if @times[(newest - age) % FRAMES] <= played
71
+
72
+ age += 1
73
+ end
74
+ ((((newest - age) % FRAMES) * @sid_count) + sid) * FIELDS
75
+ end
76
+
77
+ def register(frame, reg) = @states[frame + reg]
78
+
79
+ # The voice's oscillator frequency in hertz, at the SID's clock.
80
+ def hertz(frame, voice, clock_hz)
81
+ base = voice * 7
82
+ (register(frame, base) | (register(frame, base + 1) << 8)) * clock_hz / 16_777_216.0
83
+ end
84
+
85
+ def level(frame, voice) = @states[frame + LEVELS + voice]
86
+
87
+ def state(frame, voice) = STATES[@states[frame + PHASES + voice]]
88
+
89
+ # The first of `count` samples of `channel` heard up to `played`
90
+ # seconds, moved back to where the wave last rose through its middle,
91
+ # so a steady tone holds still from one frame to the next.
92
+ def scope_start(played, count, channel = MIX)
93
+ last = @written - ((@rendered - played) * @rate).round
94
+ last = @written if last > @written
95
+ earliest = [@written - SAMPLES, 0].max
96
+ start = last - count
97
+ return earliest if start < earliest
98
+
99
+ rising(start, [start - count, earliest].max, channel)
100
+ end
101
+
102
+ def sample_at(index, channel = MIX) = @samples[((index % SAMPLES) * @channels) + channel]
103
+
104
+ # The middle of the range `channel` covers over `count` samples from
105
+ # `start`, which a voice's scope centres on.
106
+ def middle(start, count, channel)
107
+ low = high = sample_at(start, channel)
108
+ index = start + 1
109
+ while index < start + count
110
+ value = sample_at(index, channel)
111
+ low = value if value < low
112
+ high = value if value > high
113
+ index += 1
114
+ end
115
+ (low + high) / 2
116
+ end
117
+
118
+ private
119
+
120
+ def keep_state(sid, base)
121
+ states = @states
122
+ 25.times { |reg| states[base + reg] = sid.register(reg) }
123
+ voices = sid.voices
124
+ 3.times do |voice|
125
+ envelope = voices[voice].envelope
126
+ states[base + LEVELS + voice] = envelope.counter
127
+ states[base + PHASES + voice] = STATES.index(envelope.state) || 0
128
+ end
129
+ end
130
+
131
+ def keep(sids, outputs, samples)
132
+ first = @written
133
+ sids.size.times { |index| keep_sid(first, index * 4, outputs[index], sids[index].drain_voice_samples) }
134
+ stored = @samples
135
+ channels = @channels
136
+ left = channels - 2
137
+ i = 0
138
+ while (i * 2) + 1 < samples.size
139
+ slot = ((@written % SAMPLES) * channels) + left
140
+ stored[slot] = samples[i * 2]
141
+ stored[slot + 1] = samples[(i * 2) + 1]
142
+ @written += 1
143
+ i += 1
144
+ end
145
+ end
146
+
147
+ def keep_sid(first, channel, output, voices)
148
+ stored = @samples
149
+ channels = @channels
150
+ i = 0
151
+ while i < output.size
152
+ slot = (((first + i) % SAMPLES) * channels) + channel
153
+ stored[slot + MIX] = output[i]
154
+ if voices.size > (i * 3) + 2
155
+ stored[slot] = voices[i * 3]
156
+ stored[slot + 1] = voices[(i * 3) + 1]
157
+ stored[slot + 2] = voices[(i * 3) + 2]
158
+ end
159
+ i += 1
160
+ end
161
+ end
162
+
163
+ def rising(start, limit, channel)
164
+ middle = middle(limit, start - limit + 1, channel)
165
+ index = start
166
+ while index > limit
167
+ return index if sample_at(index - 1, channel) < middle && sample_at(index, channel) >= middle
168
+
169
+ index -= 1
170
+ end
171
+ start
172
+ end
173
+ end
174
+ end
175
+ end
@@ -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
@@ -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