badline 0.6.1 → 0.8.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 +56 -0
- data/CLAUDE.md +3 -1
- data/README.md +132 -247
- data/doc/library.md +62 -0
- data/doc/media.md +69 -0
- data/doc/sid-player.md +132 -0
- data/doc/snapshots.md +69 -0
- 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/cartridge/freezer.rb +2 -0
- data/lib/badline/cartridge.rb +3 -0
- data/lib/badline/frontend/app.rb +38 -35
- data/lib/badline/frontend/audio_sink.rb +6 -5
- data/lib/badline/frontend/boot.rb +9 -2
- data/lib/badline/frontend/buttons.rb +184 -0
- data/lib/badline/frontend/confirmation.rb +50 -0
- data/lib/badline/frontend/controls.rb +44 -32
- data/lib/badline/frontend/file_browser.rb +225 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_report.rb +52 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/menu_dialogs.rb +122 -0
- data/lib/badline/frontend/menu_media.rb +128 -0
- data/lib/badline/frontend/menu_pages.rb +198 -0
- data/lib/badline/frontend/painter.rb +185 -0
- data/lib/badline/frontend/pause_menu.rb +246 -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/snapshots.rb +1 -1
- data/lib/badline/frontend/sound.rb +0 -1
- data/lib/badline/frontend/timeline.rb +5 -1
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +21 -0
- data/lib/badline/media/disk_set.rb +108 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +14 -8
- data/lib/badline/options/help.rb +34 -5
- data/lib/badline/options/option.rb +1 -1
- data/lib/badline/options/table.rb +5 -3
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +65 -46
- 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 +36 -1
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# A .sid tune in the player's Media::Queue, read only once the queue or
|
|
6
|
+
# the player first asks about it, so a queue of a whole collection
|
|
7
|
+
# starts at once. Its subtunes, SID model and lengths follow the options:
|
|
8
|
+
# --subtune picks the subtune to start on, --sid the model and --seconds the
|
|
9
|
+
# length, and without them the tune's own header and HVSC's database
|
|
10
|
+
# decide.
|
|
11
|
+
#
|
|
12
|
+
# A file that won't read as a tune, or lacks the subtune asked for, gets
|
|
13
|
+
# an #error and a single subtune.
|
|
14
|
+
class QueuedTune < Media::Queue::Entry
|
|
15
|
+
def initialize(path, options, subtune: nil)
|
|
16
|
+
super(path)
|
|
17
|
+
@options = options
|
|
18
|
+
@subtune = subtune
|
|
19
|
+
@tune = nil
|
|
20
|
+
@read = false
|
|
21
|
+
@error = ""
|
|
22
|
+
@lengths = nil
|
|
23
|
+
@lengths_read = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def part
|
|
27
|
+
read
|
|
28
|
+
@part
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def parts
|
|
32
|
+
read
|
|
33
|
+
@parts
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Why the tune can't play, or "" when it can.
|
|
37
|
+
def error
|
|
38
|
+
read
|
|
39
|
+
@error
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def tune = @tune ||= Storage::SIDFile.new(path)
|
|
43
|
+
|
|
44
|
+
# Lets go of the tune's data, which #tune reads again if asked.
|
|
45
|
+
def release = @tune = nil
|
|
46
|
+
|
|
47
|
+
# Each SID's model, SID 1's first: the one --sid names for all of
|
|
48
|
+
# them, or each one's own.
|
|
49
|
+
def sid_models
|
|
50
|
+
forced = @options.sid_model
|
|
51
|
+
forced ? Array.new(tune.sids, forced) : tune.sid_models
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def model_name = sid_models.map { |model| model.to_s.delete_prefix("mos") }.join(" + ")
|
|
55
|
+
|
|
56
|
+
def length(subtune) = @options.seconds || songlengths&.at(subtune - 1) || @options.fallback_seconds
|
|
57
|
+
|
|
58
|
+
# How long the subtune may stay silent before it ends, or nil when its
|
|
59
|
+
# length is known rather than the fallback.
|
|
60
|
+
def silence(subtune) = @options.seconds || songlengths&.at(subtune - 1) ? nil : @options.silence_seconds
|
|
61
|
+
|
|
62
|
+
# The header's name, author and release, those it fills in.
|
|
63
|
+
def header = [tune.name, tune.author, tune.released].reject(&:empty?)
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def read
|
|
68
|
+
return if @read
|
|
69
|
+
|
|
70
|
+
@read = true
|
|
71
|
+
@parts = tune.subtunes
|
|
72
|
+
@part = @subtune || tune.start_subtune
|
|
73
|
+
@error = "no subtune #{@part}: the tune has #{@parts}" if @part > @parts
|
|
74
|
+
rescue Storage::SIDFile::FormatError => e
|
|
75
|
+
@error = e.message
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# HVSC's database is keyed by the tune's MD5 and lists one length per
|
|
79
|
+
# subtune.
|
|
80
|
+
def songlengths
|
|
81
|
+
return @lengths if @lengths_read
|
|
82
|
+
|
|
83
|
+
@lengths_read = true
|
|
84
|
+
database = @options.songlengths || Storage::SongLengths.locate(path)
|
|
85
|
+
@lengths = database && Storage::SongLengths.new(database).lengths(tune.md5)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -3,40 +3,70 @@
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
5
|
# Renders a .sid tune to a PCM file, or streams its samples to whoever
|
|
6
|
-
# plays them
|
|
7
|
-
#
|
|
6
|
+
# plays them, in stereo with the left and right interleaved (Stereo).
|
|
7
|
+
# A PSID tune with a play address runs on the bare rig; anything else
|
|
8
|
+
# drives its own interrupts and needs the whole machine.
|
|
8
9
|
#
|
|
9
10
|
# A .sid file carries no length, so the caller says how many seconds to
|
|
10
|
-
# render.
|
|
11
|
+
# render. For a tune whose length is a guess, #silence= also ends it
|
|
12
|
+
# once the output has held still for that many seconds.
|
|
11
13
|
class Renderer
|
|
12
14
|
class UnknownFormatError < StandardError; end
|
|
13
15
|
|
|
14
16
|
DEFAULT_RATE = 44_100
|
|
15
17
|
|
|
18
|
+
# How far a sample may stray from where the output settled and still
|
|
19
|
+
# count as silence. A 6581 settles at a DC offset rather than at zero.
|
|
20
|
+
QUIET = 16
|
|
21
|
+
|
|
16
22
|
CONTAINERS = { ".wav" => WAV, ".aiff" => AIFF, ".aif" => AIFF }.freeze
|
|
17
23
|
|
|
18
24
|
# The cycles the SID's filter integrates at a time; 1 renders the
|
|
19
25
|
# filter cycle by cycle, exactly.
|
|
20
26
|
attr_writer :filter_chunk
|
|
21
27
|
|
|
22
|
-
|
|
28
|
+
# The seconds rendered so far, short of the length once the tune has
|
|
29
|
+
# fallen silent.
|
|
30
|
+
attr_reader :rendered, :tune, :rate
|
|
31
|
+
|
|
32
|
+
# Hears each frame's samples and the seconds rendered by its end, as
|
|
33
|
+
# they are rendered.
|
|
34
|
+
attr_writer :observer
|
|
35
|
+
|
|
36
|
+
# Where #stream starts: the frames before it are rendered as fast as
|
|
37
|
+
# they go and yielded without their samples, so the seconds rendered
|
|
38
|
+
# still count up to it. Set while streaming, it skips ahead from the
|
|
39
|
+
# frame rendered last.
|
|
40
|
+
attr_writer :from
|
|
41
|
+
|
|
42
|
+
def initialize(tune, seconds:, subtune: nil, rate: DEFAULT_RATE, sid_models: tune.sid_models)
|
|
23
43
|
@tune = tune
|
|
24
44
|
@seconds = seconds
|
|
25
|
-
@
|
|
45
|
+
@subtune = subtune
|
|
26
46
|
@rate = rate
|
|
27
|
-
@
|
|
47
|
+
@sid_models = sid_models
|
|
28
48
|
@filter_chunk = SID::FILTER_CHUNK
|
|
49
|
+
@silent_samples = 0
|
|
50
|
+
@level = 0
|
|
51
|
+
@still = 0
|
|
52
|
+
@rendered = 0.0
|
|
53
|
+
@observer = ->(_samples, _rendered) {}
|
|
54
|
+
@from = 0.0
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def silence=(seconds)
|
|
58
|
+
@silent_samples = (seconds * @rate).round
|
|
29
59
|
end
|
|
30
60
|
|
|
31
61
|
def player
|
|
32
|
-
@player ||= (bare? ? BarePlayer : MachinePlayer).new(@tune,
|
|
62
|
+
@player ||= (bare? ? BarePlayer : MachinePlayer).new(@tune, subtune: @subtune, sid_models: @sid_models)
|
|
33
63
|
end
|
|
34
64
|
|
|
35
65
|
# Yields the seconds rendered so far after every frame.
|
|
36
66
|
def render(path)
|
|
37
67
|
container = container_for(path)
|
|
38
68
|
player.start
|
|
39
|
-
container.open(path, rate: @rate) do |writer|
|
|
69
|
+
container.open(path, rate: @rate, channels: 2) do |writer|
|
|
40
70
|
each_frame do |samples, seconds|
|
|
41
71
|
samples.each { |sample| writer << sample }
|
|
42
72
|
yield seconds if block_given?
|
|
@@ -71,14 +101,31 @@ module Badline
|
|
|
71
101
|
end
|
|
72
102
|
|
|
73
103
|
def each_frame
|
|
74
|
-
player.
|
|
104
|
+
player.stereo.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
|
|
75
105
|
total = total_cycles
|
|
76
106
|
remaining = total
|
|
77
107
|
while remaining.positive?
|
|
78
108
|
samples = []
|
|
79
109
|
remaining -= player.frame(remaining) { |sample| samples << sample }
|
|
80
|
-
|
|
110
|
+
@rendered = (total - remaining).fdiv(player.clock_hz)
|
|
111
|
+
@observer.call(samples, @rendered)
|
|
112
|
+
yield(@rendered < @from ? [] : samples, @rendered)
|
|
113
|
+
break if fallen_silent?(samples)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Counts the samples since the output last strayed more than QUIET
|
|
118
|
+
# from where it settled.
|
|
119
|
+
def fallen_silent?(samples)
|
|
120
|
+
return false if @silent_samples.zero? || samples.empty?
|
|
121
|
+
|
|
122
|
+
if samples.max - @level > QUIET || @level - samples.min > QUIET
|
|
123
|
+
@level = samples.last
|
|
124
|
+
@still = 0
|
|
125
|
+
else
|
|
126
|
+
@still += samples.length / 2
|
|
81
127
|
end
|
|
128
|
+
@still >= @silent_samples
|
|
82
129
|
end
|
|
83
130
|
end
|
|
84
131
|
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# The SIDs a tune plays on, the bus's own first and the extra ones at
|
|
6
|
+
# the addresses the tune's header gives, each on its own model. Their
|
|
7
|
+
# output mixes to interleaved stereo: SID 1 on the left, SID 2 on the
|
|
8
|
+
# right and SID 3 in the centre, at half on each side. A tune on one SID
|
|
9
|
+
# plays the same on both channels.
|
|
10
|
+
class Stereo
|
|
11
|
+
# The SIDs, in the order the header numbers them.
|
|
12
|
+
attr_reader :sids
|
|
13
|
+
|
|
14
|
+
# Each SID's own samples, as the last #mix drained them.
|
|
15
|
+
attr_reader :outputs
|
|
16
|
+
|
|
17
|
+
def initialize(bus, tune, models)
|
|
18
|
+
@sids = [bus.sid]
|
|
19
|
+
@extras = []
|
|
20
|
+
tune.sid_addresses.each do |address|
|
|
21
|
+
sid = SID.new(model: models[@sids.size], at: address)
|
|
22
|
+
bus.add_sid(sid)
|
|
23
|
+
@sids << sid
|
|
24
|
+
@extras << sid
|
|
25
|
+
end
|
|
26
|
+
@fitted = models
|
|
27
|
+
@outputs = []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The SIDs past the bus's own, which the player clocks itself.
|
|
31
|
+
attr_reader :extras
|
|
32
|
+
|
|
33
|
+
def synthesize! = @sids.each(&:synthesize!)
|
|
34
|
+
|
|
35
|
+
# The model each SID plays on.
|
|
36
|
+
def models = @sids.map(&:model)
|
|
37
|
+
|
|
38
|
+
# Puts every SID on `model`, or with :auto each back on the one it was
|
|
39
|
+
# fitted with.
|
|
40
|
+
def refit(model)
|
|
41
|
+
@sids.size.times do |index|
|
|
42
|
+
wanted = model == :auto ? @fitted[index] : model
|
|
43
|
+
sid = @sids[index]
|
|
44
|
+
sid.model = wanted unless sid.model == wanted
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record(rate:, filter_chunk:, clock_hz:)
|
|
49
|
+
@sids.each { |sid| sid.record(rate:, filter_chunk:, clock_hz:) }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Drains every SID and yields the left and the right of each sample
|
|
53
|
+
# in turn.
|
|
54
|
+
def mix(&)
|
|
55
|
+
outputs = @outputs
|
|
56
|
+
outputs.clear
|
|
57
|
+
@sids.each { |sid| outputs << sid.drain_samples }
|
|
58
|
+
case outputs.size
|
|
59
|
+
when 1 then both(outputs[0], &)
|
|
60
|
+
when 2 then pair(outputs[0], outputs[1], &)
|
|
61
|
+
else centred(outputs[0], outputs[1], outputs[2], &)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def both(samples)
|
|
68
|
+
i = 0
|
|
69
|
+
while i < samples.size
|
|
70
|
+
yield samples[i]
|
|
71
|
+
yield samples[i]
|
|
72
|
+
i += 1
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def pair(left, right)
|
|
77
|
+
count = [left.size, right.size].min
|
|
78
|
+
i = 0
|
|
79
|
+
while i < count
|
|
80
|
+
yield left[i]
|
|
81
|
+
yield right[i]
|
|
82
|
+
i += 1
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def centred(left, right, centre)
|
|
87
|
+
count = [left.size, right.size, centre.size].min
|
|
88
|
+
i = 0
|
|
89
|
+
while i < count
|
|
90
|
+
yield ((2 * left[i]) + centre[i]) / 3
|
|
91
|
+
yield ((2 * right[i]) + centre[i]) / 3
|
|
92
|
+
i += 1
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -10,9 +10,13 @@ module Badline
|
|
|
10
10
|
# stty and poll(2) for the native badline.
|
|
11
11
|
class Terminal
|
|
12
12
|
KEYS = {
|
|
13
|
-
"n" => :next, "
|
|
14
|
-
"
|
|
13
|
+
"n" => :next, "p" => :previous,
|
|
14
|
+
"\e[C" => :next_subtune, "\e[D" => :previous_subtune,
|
|
15
15
|
" " => :pause,
|
|
16
|
+
"s" => :shuffle,
|
|
17
|
+
"l" => :loop,
|
|
18
|
+
"a" => :all_subtunes,
|
|
19
|
+
"," => :back, "." => :forward,
|
|
16
20
|
"q" => :quit
|
|
17
21
|
}.freeze
|
|
18
22
|
|
|
@@ -20,6 +24,8 @@ module Badline
|
|
|
20
24
|
@input = input
|
|
21
25
|
@output = output
|
|
22
26
|
@closed = false
|
|
27
|
+
@tune = 1
|
|
28
|
+
@tunes = 1
|
|
23
29
|
end
|
|
24
30
|
|
|
25
31
|
# Raw input and a hidden cursor for the length of the block.
|
|
@@ -46,12 +52,39 @@ module Badline
|
|
|
46
52
|
|
|
47
53
|
def header(lines)
|
|
48
54
|
lines.each { |line| @output.print "#{line}\r\n" }
|
|
49
|
-
@output.print "
|
|
55
|
+
@output.print "←/→ subtune n/p tune ,/. seek space pause s shuffle l loop a all subtunes q quit\r\n"
|
|
50
56
|
end
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
# Prints lines above the status line, such as the next tune's header.
|
|
59
|
+
def announce(lines)
|
|
60
|
+
lines.each { |line| @output.print "\r\e[K#{line}\r\n" }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The tune's place in the queue, which the status line shows once
|
|
64
|
+
# there is more than one.
|
|
65
|
+
def place(tune, tunes)
|
|
66
|
+
@tune = tune
|
|
67
|
+
@tunes = tunes
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Where :seek asks the subtune to play from, in seconds. Only a window
|
|
71
|
+
# asks for it, by a click on the time the subtune has played.
|
|
72
|
+
def seek_to = 0.0
|
|
73
|
+
|
|
74
|
+
# The files dropped on a window since it last said :drop.
|
|
75
|
+
def dropped = []
|
|
76
|
+
|
|
77
|
+
# Hears of the STIL fields for the tune and for the subtune starting,
|
|
78
|
+
# which the terminal prints as the CLI announces them.
|
|
79
|
+
def stil(fields, subtune, subtune_fields) = [fields, subtune, subtune_fields]
|
|
80
|
+
|
|
81
|
+
# Hears of each subtune's renderer as it starts playing.
|
|
82
|
+
def playing(renderer) = renderer
|
|
83
|
+
|
|
84
|
+
def status(subtune:, subtunes:, elapsed:, length:, notes: [])
|
|
85
|
+
line = format("subtune %<subtune>d/%<subtunes>d %<elapsed>s / %<length>s",
|
|
86
|
+
subtune:, subtunes:, elapsed: clock(elapsed), length: clock(length))
|
|
87
|
+
line = format("tune %<tune>d/%<tunes>d %<line>s", tune: @tune, tunes: @tunes, line:) if @tunes > 1
|
|
55
88
|
@output.print "\r\e[K#{([line] + notes).join(' ')}"
|
|
56
89
|
@output.flush
|
|
57
90
|
end
|
data/lib/badline/audio/wav.rb
CHANGED
data/lib/badline/audio.rb
CHANGED
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
require "badline/audio/pcm_writer"
|
|
4
4
|
require "badline/audio/wav"
|
|
5
5
|
require "badline/audio/aiff"
|
|
6
|
+
require "badline/audio/stereo"
|
|
6
7
|
require "badline/audio/bare_player"
|
|
7
8
|
require "badline/audio/machine_player"
|
|
8
9
|
require "badline/audio/renderer"
|
|
9
10
|
require "badline/audio/playback"
|
|
10
11
|
require "badline/audio/terminal"
|
|
11
12
|
require "badline/audio/jukebox"
|
|
13
|
+
require "badline/audio/queued_tune"
|
|
12
14
|
require "badline/audio/cli"
|
data/lib/badline/cartridge.rb
CHANGED
data/lib/badline/frontend/app.rb
CHANGED
|
@@ -7,8 +7,8 @@ module Badline
|
|
|
7
7
|
# changed lines, present and wait. Pacer decides the cycles and the wait.
|
|
8
8
|
class App
|
|
9
9
|
SCALE = 2
|
|
10
|
+
DROPFILE = 0x1000
|
|
10
11
|
TITLE = "Badline"
|
|
11
|
-
STAGES = %w[events emulate audio blit present wait].freeze
|
|
12
12
|
|
|
13
13
|
# Takes the frame limit, the pacing, the snapshot path, the sound and
|
|
14
14
|
# the verbosity from Options, and runs the timeline's events.
|
|
@@ -22,11 +22,11 @@ module Badline
|
|
|
22
22
|
@screen = Screen.new(computer.vic)
|
|
23
23
|
@led = DriveLed.for(computer)
|
|
24
24
|
@controls = Controls.new(computer)
|
|
25
|
-
@spent = Array.new(STAGES.size, 0.0)
|
|
26
|
-
@slowest = 0.0
|
|
27
25
|
open_window
|
|
28
26
|
@sound = Sound.new(computer.sid, options.sound?, @verbose)
|
|
29
27
|
@gamepads = Gamepads.new(computer, @verbose)
|
|
28
|
+
@frame_report = FrameReport.new(@sound)
|
|
29
|
+
@menu = PauseMenu.new(Painter.new(@renderer), options)
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
def run
|
|
@@ -34,8 +34,7 @@ module Badline
|
|
|
34
34
|
@running = true
|
|
35
35
|
@started = @reported = now
|
|
36
36
|
@pacer.start(@started)
|
|
37
|
-
@
|
|
38
|
-
frame while @running
|
|
37
|
+
@menu.open? ? paused_frame : frame while @running
|
|
39
38
|
@snapshots.finish
|
|
40
39
|
@computer.drive1541&.flush
|
|
41
40
|
@gamepads.close
|
|
@@ -64,9 +63,7 @@ module Badline
|
|
|
64
63
|
end
|
|
65
64
|
|
|
66
65
|
def finish_frame(stamps)
|
|
67
|
-
|
|
68
|
-
took = stamps[-2] - stamps.first
|
|
69
|
-
@slowest = took if took > @slowest
|
|
66
|
+
@frame_report.add(stamps) if @verbose
|
|
70
67
|
@frames += 1
|
|
71
68
|
@timeline.run(@computer, @frames)
|
|
72
69
|
@running = false if @frames == @frame_limit || @timeline.quit?(@frames)
|
|
@@ -95,8 +92,6 @@ module Badline
|
|
|
95
92
|
@texture = SDL.SDL_CreateTexture(
|
|
96
93
|
@renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
|
|
97
94
|
)
|
|
98
|
-
SDL.rect_w(SDL.rect, Screen::WIDTH)
|
|
99
|
-
SDL.rect_h(SDL.rect, Screen::HEIGHT)
|
|
100
95
|
end
|
|
101
96
|
|
|
102
97
|
def close_window
|
|
@@ -107,7 +102,7 @@ module Badline
|
|
|
107
102
|
end
|
|
108
103
|
|
|
109
104
|
def handle_events
|
|
110
|
-
handle_event(SDL.event_type(SDL.event)) while SDL.SDL_PollEvent(SDL.event) != 0
|
|
105
|
+
handle_event(SDL.event_type(SDL.event)) while !@menu.open? && SDL.SDL_PollEvent(SDL.event) != 0
|
|
111
106
|
end
|
|
112
107
|
|
|
113
108
|
def handle_event(type)
|
|
@@ -122,6 +117,7 @@ module Badline
|
|
|
122
117
|
@controls.mouse_button(SDL.event_button(SDL.event), type == SDL::MOUSEBUTTONDOWN)
|
|
123
118
|
when SDL::CONTROLLERDEVICEADDED, SDL::CONTROLLERDEVICEREMOVED
|
|
124
119
|
@gamepads.rescan
|
|
120
|
+
when DROPFILE then resume(false) unless @menu.drop(@computer, @controls, @sound)
|
|
125
121
|
end
|
|
126
122
|
end
|
|
127
123
|
|
|
@@ -145,20 +141,45 @@ module Badline
|
|
|
145
141
|
@controls.computer = computer
|
|
146
142
|
@gamepads.computer = computer
|
|
147
143
|
@sound.sid = computer.sid
|
|
144
|
+
@snapshots.computer = computer
|
|
148
145
|
end
|
|
149
146
|
|
|
150
147
|
def handle_toggle(scancode)
|
|
151
148
|
if scancode == Keys::TAB
|
|
152
|
-
@controls.
|
|
153
|
-
SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
|
|
149
|
+
@controls.toggle_keys
|
|
154
150
|
elsif scancode == Keys::F9
|
|
155
|
-
|
|
151
|
+
return open_menu
|
|
156
152
|
else
|
|
157
153
|
@sound.toggle_mute
|
|
158
154
|
end
|
|
159
155
|
update_title
|
|
160
156
|
end
|
|
161
157
|
|
|
158
|
+
def open_menu = @menu.show(@computer, @controls, @sound)
|
|
159
|
+
|
|
160
|
+
# Closes the menu and runs the machine on from where it stood,
|
|
161
|
+
# pressing the cartridge's freeze button if asked.
|
|
162
|
+
def resume(freeze)
|
|
163
|
+
@menu.close
|
|
164
|
+
SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
|
|
165
|
+
@timeline.press_freeze(@computer, @frames) if freeze
|
|
166
|
+
@reported = now
|
|
167
|
+
@pacer.start(@reported)
|
|
168
|
+
update_title
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Runs a frame of the menu, which has the keys and the mouse while the
|
|
172
|
+
# machine stands still.
|
|
173
|
+
def paused_frame
|
|
174
|
+
action = @menu.frame(@renderer, @texture, @controls)
|
|
175
|
+
if action == :quit
|
|
176
|
+
@running = false
|
|
177
|
+
elsif !action.nil?
|
|
178
|
+
swap(@menu.computer) if action == :swap
|
|
179
|
+
resume(action == :freeze)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
162
183
|
def update_title
|
|
163
184
|
title = TITLE
|
|
164
185
|
title += " [#{@controls.tag}]" unless @controls.tag.empty?
|
|
@@ -183,12 +204,12 @@ module Badline
|
|
|
183
204
|
|
|
184
205
|
def upload
|
|
185
206
|
@screen.update
|
|
186
|
-
SDL.SDL_UpdateTexture(@texture,
|
|
207
|
+
SDL.SDL_UpdateTexture(@texture, nil, @screen.pixels, Screen::ROW_BYTES)
|
|
187
208
|
end
|
|
188
209
|
|
|
189
210
|
def draw
|
|
190
211
|
SDL.SDL_RenderClear(@renderer)
|
|
191
|
-
SDL.SDL_RenderCopy(@renderer, @texture,
|
|
212
|
+
SDL.SDL_RenderCopy(@renderer, @texture, nil, nil)
|
|
192
213
|
@led&.draw(@renderer)
|
|
193
214
|
@timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
|
|
194
215
|
SDL.SDL_RenderPresent(@renderer)
|
|
@@ -197,28 +218,10 @@ module Badline
|
|
|
197
218
|
def report(at)
|
|
198
219
|
@pacer.check(50, at - @reported, at)
|
|
199
220
|
@pacer.measure(50, at - @reported)
|
|
200
|
-
|
|
201
|
-
@spent = Array.new(STAGES.size, 0.0)
|
|
202
|
-
@slowest = 0.0
|
|
221
|
+
@frame_report.show(at - @reported) if @verbose
|
|
203
222
|
@reported = at
|
|
204
223
|
end
|
|
205
224
|
|
|
206
|
-
def report_frames(at)
|
|
207
|
-
fps = 50 / (at - @reported)
|
|
208
|
-
stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
|
|
209
|
-
puts "#{fps.round(1)} fps, per frame ms: #{stages.join(' ')}, slowest work #{(@slowest * 1000).round(2)}"
|
|
210
|
-
report_sound(at) if @sound.on?
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
def report_sound(at)
|
|
214
|
-
sound = @sound
|
|
215
|
-
rate = (sound.queued - @reported_samples) / (at - @reported)
|
|
216
|
-
queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
|
|
217
|
-
puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
|
|
218
|
-
sound.reset_levels
|
|
219
|
-
@reported_samples = sound.queued
|
|
220
|
-
end
|
|
221
|
-
|
|
222
225
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
223
226
|
end
|
|
224
227
|
end
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Frontend
|
|
5
|
-
# The host's audio device for --headless, fed through
|
|
6
|
-
# signed 16-bit samples
|
|
7
|
-
#
|
|
5
|
+
# The host's audio device for --headless and the SID player, fed through
|
|
6
|
+
# SDL's queue: stereo signed 16-bit samples, the left and right
|
|
7
|
+
# interleaved, go in at the device's rate and SDL plays them out behind
|
|
8
|
+
# us. It answers what Audio::Playback asks of a sink.
|
|
8
9
|
class AudioSink
|
|
9
10
|
class Error < Audio::Playback::DeviceError; end
|
|
10
11
|
|
|
@@ -33,7 +34,7 @@ module Badline
|
|
|
33
34
|
check(SDL.SDL_QueueAudio(@device, buffer, bytes))
|
|
34
35
|
end
|
|
35
36
|
|
|
36
|
-
def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (
|
|
37
|
+
def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (4.0 * @rate)
|
|
37
38
|
|
|
38
39
|
def start = SDL.SDL_PauseAudioDevice(@device, 0)
|
|
39
40
|
|
|
@@ -54,7 +55,7 @@ module Badline
|
|
|
54
55
|
def open_device(rate, allowed_changes, buffer)
|
|
55
56
|
SDL.spec_freq(SDL.wanted, rate)
|
|
56
57
|
SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
|
|
57
|
-
SDL.spec_channels(SDL.wanted,
|
|
58
|
+
SDL.spec_channels(SDL.wanted, 2)
|
|
58
59
|
SDL.spec_samples(SDL.wanted, buffer)
|
|
59
60
|
@device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, allowed_changes)
|
|
60
61
|
raise Error, SDL.SDL_GetError if @device.zero?
|
|
@@ -39,12 +39,19 @@ module Badline
|
|
|
39
39
|
return computer
|
|
40
40
|
end
|
|
41
41
|
|
|
42
|
+
start(options, media, options.writable?)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A new machine built from the options, with `media`, if not nil,
|
|
46
|
+
# attached and started as the command line does, its disk writable if
|
|
47
|
+
# `writable`.
|
|
48
|
+
def self.start(options, media, writable)
|
|
42
49
|
computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
|
|
43
50
|
region: options.ntsc? ? Region::NTSC : Region::PAL)
|
|
44
51
|
Media::TrueDrive.plug(computer) if options.true_drive?
|
|
45
52
|
unless media.nil?
|
|
46
|
-
puts Media.attach(computer, media, autostart: options.autostart?,
|
|
47
|
-
disk: { read_only:
|
|
53
|
+
puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
|
|
54
|
+
disk: { read_only: !writable })
|
|
48
55
|
end
|
|
49
56
|
computer
|
|
50
57
|
end
|