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,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,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The window's snapshots: F11 saves the machine to a new
|
|
6
|
+
# badline-<date>-<time>.vsf in the working directory, and F12 goes back
|
|
7
|
+
# to the snapshot last saved or opened, in a new machine. --save-snapshot
|
|
8
|
+
# saves one after the last frame.
|
|
9
|
+
class Snapshots
|
|
10
|
+
KEYS = [Keys::F11, Keys::F12].freeze
|
|
11
|
+
|
|
12
|
+
# The machine running, which F12 replaces with the restored one.
|
|
13
|
+
attr_reader :computer
|
|
14
|
+
|
|
15
|
+
def initialize(computer, options)
|
|
16
|
+
@computer = computer
|
|
17
|
+
@last = options.snapshot? ? options.media_path.to_s : ""
|
|
18
|
+
@at_finish = options.save_snapshot
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Saves or restores for F11 or F12, and says whether a restored
|
|
22
|
+
# machine now runs in place of the one before.
|
|
23
|
+
def key(scancode)
|
|
24
|
+
return restore if scancode == Keys::F12
|
|
25
|
+
|
|
26
|
+
save if scancode == Keys::F11
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Saves the machine, and warns when it can't.
|
|
31
|
+
def save(path = "badline-#{Time.now.strftime('%Y%m%d-%H%M%S')}.vsf")
|
|
32
|
+
@computer.save_snapshot(path)
|
|
33
|
+
@last = path
|
|
34
|
+
puts "Saved #{path}"
|
|
35
|
+
rescue Snapshot::FormatError, SystemCallError => e
|
|
36
|
+
warn "badline: #{path}: #{e.message}"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Restores the snapshot last saved or opened into a new machine, and
|
|
40
|
+
# warns when it fails, leaving the machine running as it was.
|
|
41
|
+
def restore
|
|
42
|
+
if @last.empty?
|
|
43
|
+
puts "No snapshot to restore, F11 saves one"
|
|
44
|
+
return false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@computer = Snapshot.load(@last) { |line| puts line }
|
|
48
|
+
puts "Restored #{@last}"
|
|
49
|
+
true
|
|
50
|
+
rescue Snapshot::FormatError, SystemCallError => e
|
|
51
|
+
warn "badline: #{@last}: #{e.message}"
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def finish
|
|
56
|
+
save(@at_finish) unless @at_finish.empty?
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID's output on SDL's audio queue, as mono signed 16-bit samples.
|
|
6
|
+
# Once the device plays, it is the clock: #wait holds each frame until
|
|
7
|
+
# the queue is down to `AHEAD`, so the machine runs at the device's pace.
|
|
8
|
+
#
|
|
9
|
+
# The device starts once the queue holds AHEAD. Below real time the
|
|
10
|
+
# queue runs dry, and the device then stops until it holds AHEAD again,
|
|
11
|
+
# so the sound stutters with silent gaps rather than slowing down. A
|
|
12
|
+
# frame that would take the queue past LIMIT is dropped whole, which
|
|
13
|
+
# only happens unpaced.
|
|
14
|
+
#
|
|
15
|
+
# Each frame's samples go to SDL through an IO::Buffer of 16-bit values.
|
|
16
|
+
class Sound
|
|
17
|
+
RATE = 44_100
|
|
18
|
+
AHEAD = 0.08
|
|
19
|
+
LIMIT = 0.25
|
|
20
|
+
|
|
21
|
+
attr_reader :rate, :underruns, :dropped, :queued, :low, :high
|
|
22
|
+
|
|
23
|
+
def initialize(sid, wanted, verbose)
|
|
24
|
+
@sid = sid
|
|
25
|
+
@verbose = verbose
|
|
26
|
+
@device = 0
|
|
27
|
+
@rate = RATE
|
|
28
|
+
@started = false
|
|
29
|
+
@muted = false
|
|
30
|
+
@underruns = 0
|
|
31
|
+
@dropped = 0
|
|
32
|
+
@queued = 0
|
|
33
|
+
@buffer = IO::Buffer.new(4096)
|
|
34
|
+
reset_levels
|
|
35
|
+
open_device if wanted
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def on? = @device != 0
|
|
39
|
+
|
|
40
|
+
# Plays another machine's SID from here on.
|
|
41
|
+
def sid=(sid)
|
|
42
|
+
@sid = sid
|
|
43
|
+
sid.record(rate: @rate) if on?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def muted? = @muted
|
|
47
|
+
|
|
48
|
+
def playing? = @started
|
|
49
|
+
|
|
50
|
+
def feed
|
|
51
|
+
return unless on?
|
|
52
|
+
|
|
53
|
+
samples = @sid.drain_samples
|
|
54
|
+
return if @muted || samples.empty?
|
|
55
|
+
|
|
56
|
+
level = queued_seconds
|
|
57
|
+
if level + (samples.size.to_f / @rate) > LIMIT
|
|
58
|
+
@dropped += samples.size
|
|
59
|
+
else
|
|
60
|
+
play(samples, level)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# How much the audio queue holds, in seconds.
|
|
65
|
+
def level = queued_seconds
|
|
66
|
+
|
|
67
|
+
# Waits until the queue is down to `level` seconds.
|
|
68
|
+
def wait(level = AHEAD)
|
|
69
|
+
SDL.SDL_Delay(1) while queued_seconds > level
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def toggle_mute
|
|
73
|
+
@muted = !@muted
|
|
74
|
+
return unless @muted
|
|
75
|
+
|
|
76
|
+
halt
|
|
77
|
+
SDL.SDL_ClearQueuedAudio(@device)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def reset_levels
|
|
81
|
+
@low = LIMIT
|
|
82
|
+
@high = 0.0
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def close
|
|
86
|
+
SDL.SDL_CloseAudioDevice(@device) if on?
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def open_device
|
|
92
|
+
unless SDL.SDL_InitSubSystem(SDL::INIT_AUDIO).zero?
|
|
93
|
+
puts "No sound: #{SDL.SDL_GetError}"
|
|
94
|
+
return
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
SDL.spec_freq(SDL.wanted, RATE)
|
|
98
|
+
SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
|
|
99
|
+
SDL.spec_channels(SDL.wanted, 1)
|
|
100
|
+
SDL.spec_samples(SDL.wanted, 512)
|
|
101
|
+
@device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, SDL::ALLOW_FREQUENCY_CHANGE)
|
|
102
|
+
return puts "No sound: #{SDL.SDL_GetError}" unless on?
|
|
103
|
+
|
|
104
|
+
@rate = SDL.read_i32(SDL.obtained)
|
|
105
|
+
@sid.record(rate: @rate)
|
|
106
|
+
puts "Sound at #{@rate} Hz" if @verbose
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
|
|
110
|
+
|
|
111
|
+
def play(samples, level)
|
|
112
|
+
@low = level if level < @low
|
|
113
|
+
underrun! if @started && level.zero?
|
|
114
|
+
queue(samples)
|
|
115
|
+
level = queued_seconds
|
|
116
|
+
@high = level if level > @high
|
|
117
|
+
start if level >= AHEAD
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def queue(samples)
|
|
121
|
+
count = samples.size
|
|
122
|
+
bytes = count * 2
|
|
123
|
+
@buffer.resize(bytes) if bytes > @buffer.size
|
|
124
|
+
buffer = @buffer
|
|
125
|
+
i = 0
|
|
126
|
+
while i < count
|
|
127
|
+
buffer.set_value(:s16, i * 2, samples[i])
|
|
128
|
+
i += 1
|
|
129
|
+
end
|
|
130
|
+
SDL.SDL_QueueAudio(@device, buffer, bytes)
|
|
131
|
+
@queued += count
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def start
|
|
135
|
+
return if @started
|
|
136
|
+
|
|
137
|
+
@started = true
|
|
138
|
+
SDL.SDL_PauseAudioDevice(@device, 0)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def halt
|
|
142
|
+
@started = false
|
|
143
|
+
SDL.SDL_PauseAudioDevice(@device, 1)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def underrun!
|
|
147
|
+
@underruns += 1
|
|
148
|
+
puts "Running below real time, so the sound will stutter." if @underruns == 1
|
|
149
|
+
halt
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The events of --at and --script. Each runs once its number of frames
|
|
6
|
+
# has run: a screenshot saves that frame, and the rest act on the
|
|
7
|
+
# machine before the next frame is clocked. A key or the freeze button
|
|
8
|
+
# is let go Options::Event::HOLD frames after it is pressed. An event
|
|
9
|
+
# that fails ends the run.
|
|
10
|
+
class Timeline
|
|
11
|
+
# Takes the events, --screenshot's at the last frame and whether to
|
|
12
|
+
# insert disks write-protected from Options.
|
|
13
|
+
def initialize(options)
|
|
14
|
+
@events = options.timeline
|
|
15
|
+
unless options.screenshot.empty? || options.frames.zero?
|
|
16
|
+
@events += [Options::Event.new(options.frames, "screenshot", options.screenshot)]
|
|
17
|
+
end
|
|
18
|
+
@read_only = options.read_only?
|
|
19
|
+
@program = options.program
|
|
20
|
+
@releases = []
|
|
21
|
+
@failed = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# What's wrong with the events, the first key that names nothing,
|
|
25
|
+
# or an empty string.
|
|
26
|
+
def self.error(events)
|
|
27
|
+
event = events.find { |candidate| candidate.action == "key" && !key?(candidate.argument) }
|
|
28
|
+
event.nil? ? "" : "no such key: #{event.argument}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.key?(name) = name == "restore" || !keyboard_key(name).nil? || !joystick_direction(name).nil?
|
|
32
|
+
|
|
33
|
+
# The Keyboard's key for a name such as space, return or a.
|
|
34
|
+
def self.keyboard_key(name)
|
|
35
|
+
key = Keyboard.new.matrix.flatten.find { |candidate| candidate.to_s == name }
|
|
36
|
+
key.nil? ? Keyboard::COMBINATIONS.keys.find { |candidate| candidate.to_s == name } : key
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The direction of joy1-NAME or joy2-NAME, such as up or fire.
|
|
40
|
+
def self.joystick_direction(name)
|
|
41
|
+
return nil unless name.start_with?("joy1-") || name.start_with?("joy2-")
|
|
42
|
+
|
|
43
|
+
Joystick::DIRECTIONS.keys.find { |direction| direction.to_s == name[5, name.size - 5] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The screenshot's file name, with the frame's number in place of %d,
|
|
47
|
+
# or of %Nd or %0Nd padded to N digits.
|
|
48
|
+
def self.path(pattern, frame)
|
|
49
|
+
start = pattern.index("%")
|
|
50
|
+
finish = start.nil? ? nil : pattern.index("d", start)
|
|
51
|
+
return pattern if finish.nil?
|
|
52
|
+
|
|
53
|
+
width = pattern[start + 1, finish - start - 1]
|
|
54
|
+
return pattern unless width.match?(/\A\d*\z/)
|
|
55
|
+
|
|
56
|
+
number = frame.to_s.rjust(width.to_i, width.start_with?("0") ? "0" : " ")
|
|
57
|
+
pattern[0, start] + number + pattern[finish + 1, pattern.size - finish - 1]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The files the frame's screenshots go to.
|
|
61
|
+
def screenshots(frame)
|
|
62
|
+
@events.select { |event| event.frame == frame && event.action == "screenshot" }
|
|
63
|
+
.map { |event| Timeline.path(event.argument, frame) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Whether the run ends at the frame: at a quit, or once an event failed.
|
|
67
|
+
def quit?(frame) = @failed || @events.any? { |event| event.frame == frame && event.action == "quit" }
|
|
68
|
+
|
|
69
|
+
# Whether an event failed, which ends the run.
|
|
70
|
+
def failed? = @failed
|
|
71
|
+
|
|
72
|
+
# Lets go of what was held until this frame, then runs its events, up
|
|
73
|
+
# to one that fails.
|
|
74
|
+
def run(computer, frame)
|
|
75
|
+
@releases.each { |event| release(computer, event) if event.frame == frame }
|
|
76
|
+
@releases.reject! { |event| event.frame == frame }
|
|
77
|
+
@events.each { |event| perform(computer, event) if event.frame == frame && !@failed }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def perform(computer, event)
|
|
83
|
+
case event.action
|
|
84
|
+
when "key" then press(computer, event)
|
|
85
|
+
when "type" then computer.type_text(event.argument.gsub("\\n", "\r").gsub("\\r", "\r"))
|
|
86
|
+
when "insert" then insert(computer, event.argument)
|
|
87
|
+
when "eject" then eject(computer, event.argument)
|
|
88
|
+
when "reset" then computer.reset!
|
|
89
|
+
when "freeze" then freeze(computer, event)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def press(computer, event)
|
|
94
|
+
name = event.argument
|
|
95
|
+
if name == "restore"
|
|
96
|
+
computer.press_restore
|
|
97
|
+
return
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
direction = Timeline.joystick_direction(name)
|
|
101
|
+
if direction.nil?
|
|
102
|
+
computer.keyboard.press(Timeline.keyboard_key(name))
|
|
103
|
+
else
|
|
104
|
+
joystick(computer, name).press(direction)
|
|
105
|
+
end
|
|
106
|
+
hold(event)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def freeze(computer, event)
|
|
110
|
+
computer.press_cartridge_button
|
|
111
|
+
hold(event)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def hold(event)
|
|
115
|
+
@releases << Options::Event.new(event.frame + Options::Event::HOLD, event.action, event.argument)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def release(computer, event)
|
|
119
|
+
return computer.release_cartridge_button if event.action == "freeze"
|
|
120
|
+
|
|
121
|
+
name = event.argument
|
|
122
|
+
direction = Timeline.joystick_direction(name)
|
|
123
|
+
if direction.nil?
|
|
124
|
+
computer.keyboard.release(Timeline.keyboard_key(name))
|
|
125
|
+
else
|
|
126
|
+
joystick(computer, name).release(direction)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def joystick(computer, name) = name.start_with?("joy1-") ? computer.joystick1 : computer.joystick2
|
|
131
|
+
|
|
132
|
+
def insert(computer, path)
|
|
133
|
+
puts Media.attach(computer, path, autostart: false, disk: { read_only: @read_only })
|
|
134
|
+
rescue StandardError => e
|
|
135
|
+
warn "#{@program}: #{path}: #{e.message}"
|
|
136
|
+
@failed = true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def eject(computer, what)
|
|
140
|
+
return puts "No #{what} to eject" unless inserted?(computer, what)
|
|
141
|
+
|
|
142
|
+
if what == "disk"
|
|
143
|
+
eject_disk(computer)
|
|
144
|
+
elsif what == "tape"
|
|
145
|
+
computer.datasette.eject
|
|
146
|
+
else
|
|
147
|
+
computer.address_bus.detach_cartridge
|
|
148
|
+
computer.power_cycle!
|
|
149
|
+
end
|
|
150
|
+
puts "Ejected the #{what}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def inserted?(computer, what)
|
|
154
|
+
return !computer.datasette.tape.nil? if what == "tape"
|
|
155
|
+
return !computer.address_bus.cartridge.nil? if what == "cartridge"
|
|
156
|
+
|
|
157
|
+
drive = Media::TrueDrive.drive(computer)
|
|
158
|
+
drive.nil? ? computer.mounted? : !drive.disk.nil?
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def eject_disk(computer)
|
|
162
|
+
drive = Media::TrueDrive.drive(computer)
|
|
163
|
+
if drive.nil?
|
|
164
|
+
computer.unmount
|
|
165
|
+
else
|
|
166
|
+
drive.insert(nil)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
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
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "badline/sdl"
|
|
4
|
+
require "badline/frontend/boot"
|
|
5
|
+
require "badline/frontend/frame_rate"
|
|
6
|
+
require "badline/frontend/screen"
|
|
7
|
+
require "badline/frontend/drive_led"
|
|
8
|
+
require "badline/frontend/keys"
|
|
9
|
+
require "badline/frontend/snapshots"
|
|
10
|
+
require "badline/frontend/screenshot"
|
|
11
|
+
require "badline/frontend/timeline"
|
|
12
|
+
require "badline/frontend/sound"
|
|
13
|
+
require "badline/frontend/pacer"
|
|
14
|
+
require "badline/frontend/controls"
|
|
15
|
+
require "badline/frontend/pad_port"
|
|
16
|
+
require "badline/frontend/gamepads"
|
|
17
|
+
require "badline/frontend/app"
|
|
18
|
+
require "badline/frontend/audio_sink"
|
|
19
|
+
require "badline/frontend/painter"
|
|
20
|
+
require "badline/frontend/sid_history"
|
|
21
|
+
require "badline/frontend/sid_view"
|
|
22
|
+
require "badline/frontend/filter_view"
|
|
23
|
+
require "badline/frontend/scope"
|
|
24
|
+
require "badline/frontend/visualizer_view"
|
|
25
|
+
require "badline/frontend/buttons"
|
|
26
|
+
require "badline/frontend/player_state"
|
|
27
|
+
require "badline/frontend/player_header"
|
|
28
|
+
require "badline/frontend/player_footer"
|
|
29
|
+
require "badline/frontend/info_view"
|
|
30
|
+
require "badline/frontend/player_commands"
|
|
31
|
+
require "badline/frontend/player_screen"
|
|
32
|
+
require "badline/frontend/player_window"
|