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,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
|