badline 0.6.0 → 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 +77 -0
- data/CLAUDE.md +10 -9
- data/README.md +135 -42
- data/doc/pinned-behaviour.md +131 -30
- data/exe/badline-ruby +28 -24
- 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 +15 -1
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +167 -59
- data/lib/badline/audio/console.rb +2 -1
- 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 -3
- data/lib/badline/computer.rb +6 -3
- data/lib/badline/cpu/boundary.rb +54 -0
- data/lib/badline/cpu/core.rb +165 -0
- data/lib/badline/cpu.rb +3 -214
- data/lib/badline/drive1541/cpu.rb +29 -0
- data/lib/badline/drive1541.rb +1 -0
- data/lib/badline/ffi.rb +157 -0
- data/lib/badline/frontend/app.rb +225 -0
- data/lib/badline/frontend/audio_sink.rb +73 -0
- data/lib/badline/frontend/boot.rb +52 -0
- data/lib/badline/frontend/buttons.rb +90 -0
- data/lib/badline/frontend/controls.rb +163 -0
- data/lib/badline/frontend/drive_led.rb +51 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_rate.rb +83 -0
- data/lib/badline/frontend/gamepads.rb +76 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/keys.rb +48 -0
- data/lib/badline/frontend/pacer.rb +100 -0
- data/lib/badline/frontend/pad_port.rb +61 -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/screen.rb +65 -0
- data/lib/badline/frontend/screenshot.rb +22 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -0
- data/lib/badline/frontend/snapshots.rb +60 -0
- data/lib/badline/frontend/sound.rb +153 -0
- data/lib/badline/frontend/timeline.rb +171 -0
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +32 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +13 -8
- data/lib/badline/options/event.rb +94 -0
- data/lib/badline/options/help.rb +107 -0
- data/lib/badline/options/option.rb +29 -0
- data/lib/badline/options/table.rb +64 -0
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +204 -149
- data/lib/badline/reu/dma/saved_state.rb +50 -0
- data/lib/badline/reu/dma.rb +56 -47
- data/lib/badline/reu/saved_state.rb +2 -1
- data/lib/badline/reu.rb +19 -10
- data/lib/badline/sdl.rb +133 -51
- 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/noise_writeback.rb +33 -11
- data/lib/badline/sid/waveform.rb +22 -8
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/snapshot/state.rb +1 -1
- 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
- data/lib/badline/vic/bank.rb +21 -0
- data/lib/badline/vic.rb +15 -6
- metadata +49 -19
- data/.claude/hooks/session-start.sh +0 -57
- data/.claude/settings.json +0 -14
- data/lib/badline/audio/sdl_sink.rb +0 -70
- data/lib/badline/audio/stream.rb +0 -123
- data/lib/badline/gui/application.rb +0 -243
- data/lib/badline/gui/drive_led_pane.rb +0 -29
- data/lib/badline/gui/gamepads.rb +0 -104
- data/lib/badline/gui/joy_map.rb +0 -27
- data/lib/badline/gui/key_map.rb +0 -39
- data/lib/badline/gui/palette.rb +0 -35
- data/lib/badline/gui/pane.rb +0 -36
- data/lib/badline/gui/screen_pane.rb +0 -46
- data/lib/badline/gui/snapshots.rb +0 -67
- data/lib/badline/gui/window.rb +0 -61
- data/lib/badline/gui.rb +0 -14
- data/lib/badline/sdl/events.rb +0 -49
- data/lib/badline/sdl/functions.rb +0 -74
- data/vendor/.gitkeep +0 -0
|
@@ -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
|
|
@@ -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,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The visible part of the VIC's display, the crop of the machine's
|
|
6
|
+
# region, repacked for an SDL texture in XRGB8888. Spinel hands an
|
|
7
|
+
# Array of Integers to C as 64-bit words, as badline/ffi does on CRuby,
|
|
8
|
+
# so each word carries two neighbouring pixels, the left one in the low
|
|
9
|
+
# half. The texture is PAL's crop, 384x272, and a region with fewer
|
|
10
|
+
# lines to show sits in the middle of it, between black bands.
|
|
11
|
+
class Screen
|
|
12
|
+
WIDTH = Region::PAL.crop[2]
|
|
13
|
+
HEIGHT = Region::PAL.crop[3]
|
|
14
|
+
ROW_BYTES = WIDTH * 4
|
|
15
|
+
ROW_WORDS = WIDTH / 2
|
|
16
|
+
|
|
17
|
+
COLORS = [
|
|
18
|
+
0x000000, 0xffffff, 0x924a40, 0x84c5cc,
|
|
19
|
+
0x9351b6, 0x72b14b, 0x483aaa, 0xd5df7c,
|
|
20
|
+
0x675200, 0xc33d00, 0xc18178, 0x606060,
|
|
21
|
+
0x8a8a8a, 0xb3ec91, 0x867ade, 0xb3b3b3
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
PAIRS = Array.new(256) { |pair| COLORS[pair & 0x0f] | (COLORS[pair >> 4] << 32) }.freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :pixels
|
|
27
|
+
|
|
28
|
+
def initialize(vic)
|
|
29
|
+
@vic = vic
|
|
30
|
+
crop = vic.region.crop
|
|
31
|
+
@col_offset = crop[0]
|
|
32
|
+
@row_offset = crop[1]
|
|
33
|
+
@height = crop[3]
|
|
34
|
+
@band = (HEIGHT - @height) / 2
|
|
35
|
+
@pixels = Array.new(HEIGHT * ROW_WORDS, 0)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Repacks only the lines the VIC has changed since the last frame.
|
|
39
|
+
def update
|
|
40
|
+
dirty = @vic.dirty_lines
|
|
41
|
+
row = 0
|
|
42
|
+
while row < @height
|
|
43
|
+
pack_row(row) if dirty[row + @row_offset]
|
|
44
|
+
row += 1
|
|
45
|
+
end
|
|
46
|
+
@vic.clear_dirty_lines!
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def pack_row(row)
|
|
52
|
+
display = @vic.display
|
|
53
|
+
pixels = @pixels
|
|
54
|
+
from = ((row + @row_offset) * @vic.width) + @col_offset
|
|
55
|
+
to = (row + @band) * ROW_WORDS
|
|
56
|
+
last = to + ROW_WORDS
|
|
57
|
+
while to < last
|
|
58
|
+
pixels[to] = PAIRS[(display[from] & 0x0f) | ((display[from + 1] & 0x0f) << 4)]
|
|
59
|
+
from += 2
|
|
60
|
+
to += 1
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# --screenshot's frame: what the renderer drew, read back before it is
|
|
6
|
+
# presented and saved as a BMP.
|
|
7
|
+
module Screenshot
|
|
8
|
+
def self.write(renderer, path)
|
|
9
|
+
SDL.SDL_GetRendererOutputSize(renderer, SDL.output_w, SDL.output_h)
|
|
10
|
+
width = SDL.read_i32(SDL.output_w)
|
|
11
|
+
height = SDL.read_i32(SDL.output_h)
|
|
12
|
+
data = LibC.malloc(width * height * 4)
|
|
13
|
+
SDL.SDL_RenderReadPixels(renderer, nil, SDL::PIXELFORMAT_RGB888, data, width * 4)
|
|
14
|
+
surface = SDL.SDL_CreateRGBSurfaceWithFormatFrom(data, width, height, 32, width * 4, SDL::PIXELFORMAT_RGB888)
|
|
15
|
+
SDL.SDL_SaveBMP_RW(surface, SDL.SDL_RWFromFile(path, "wb"), 1)
|
|
16
|
+
SDL.SDL_FreeSurface(surface)
|
|
17
|
+
LibC.free(data)
|
|
18
|
+
puts "wrote #{path}, #{width}x#{height}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
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
|