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,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"
|
|
@@ -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?
|
|
@@ -43,7 +43,7 @@ module Badline
|
|
|
43
43
|
region: options.ntsc? ? Region::NTSC : Region::PAL)
|
|
44
44
|
Media::TrueDrive.plug(computer) if options.true_drive?
|
|
45
45
|
unless media.nil?
|
|
46
|
-
puts Media.attach(computer, media, autostart: options.autostart?,
|
|
46
|
+
puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
|
|
47
47
|
disk: { read_only: options.read_only? })
|
|
48
48
|
end
|
|
49
49
|
computer
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# The SID player's buttons: each is drawn where it goes on every frame,
|
|
6
|
+
# and remembers its place, so a click finds the action under it. The
|
|
7
|
+
# one under the pointer lights up, and one that's on is drawn reversed.
|
|
8
|
+
class Buttons
|
|
9
|
+
PAD = 2
|
|
10
|
+
HEIGHT = Painter::GLYPH + (PAD * 2)
|
|
11
|
+
|
|
12
|
+
def initialize(painter)
|
|
13
|
+
@painter = painter
|
|
14
|
+
@lefts = []
|
|
15
|
+
@tops = []
|
|
16
|
+
@widths = []
|
|
17
|
+
@heights = []
|
|
18
|
+
@actions = []
|
|
19
|
+
@pointer_x = -1
|
|
20
|
+
@pointer_y = -1
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def forget
|
|
24
|
+
@lefts.clear
|
|
25
|
+
@tops.clear
|
|
26
|
+
@widths.clear
|
|
27
|
+
@heights.clear
|
|
28
|
+
@actions.clear
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def point(left, top)
|
|
32
|
+
@pointer_x = left
|
|
33
|
+
@pointer_y = top
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Draws a button labelled `label` with its top left at `left`, `top`,
|
|
37
|
+
# and returns its width.
|
|
38
|
+
def text(left, top, label, action, on: false)
|
|
39
|
+
width = Painter.width(label) + (PAD * 2)
|
|
40
|
+
place(left, top, width, HEIGHT, action)
|
|
41
|
+
@painter.box(left, top, width, HEIGHT, SIDView::TEXT) if on
|
|
42
|
+
@painter.text(left + PAD, top + PAD, label, on ? PlayerScreen::BACKGROUND : color(@actions.size - 1))
|
|
43
|
+
width
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Draws an icon button `scale` times the size of a glyph, and returns
|
|
47
|
+
# its width.
|
|
48
|
+
def icon(left, top, name, action, scale: 1)
|
|
49
|
+
size = (Painter::GLYPH * scale) + (PAD * 2)
|
|
50
|
+
place(left, top, size, size, action)
|
|
51
|
+
@painter.icon(name, left + PAD, top + PAD, color(@actions.size - 1), scale:)
|
|
52
|
+
size
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Makes a box drawn elsewhere clickable, such as the bar showing the
|
|
56
|
+
# time played.
|
|
57
|
+
def area(box, action)
|
|
58
|
+
place(box[0], box[1], box[2], box[3], action)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The action of the button at `left`, `top`, or nil.
|
|
62
|
+
def action_at(left, top)
|
|
63
|
+
index = 0
|
|
64
|
+
while index < @actions.size
|
|
65
|
+
return @actions[index] if inside?(index, left, top)
|
|
66
|
+
|
|
67
|
+
index += 1
|
|
68
|
+
end
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def place(left, top, width, height, action)
|
|
75
|
+
@lefts << left
|
|
76
|
+
@tops << top
|
|
77
|
+
@widths << width
|
|
78
|
+
@heights << height
|
|
79
|
+
@actions << action
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def color(index) = inside?(index, @pointer_x, @pointer_y) ? SIDView::BRIGHT : SIDView::TEXT
|
|
83
|
+
|
|
84
|
+
def inside?(index, left, top)
|
|
85
|
+
left >= @lefts[index] && left < @lefts[index] + @widths[index] &&
|
|
86
|
+
top >= @tops[index] && top < @tops[index] + @heights[index]
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Frontend
|
|
5
|
+
# Draws the SID's filter as the player shows it: its modes, cutoff,
|
|
6
|
+
# resonance and volume, and its response across the audible range on
|
|
7
|
+
# the chip playing, whose cutoff curve the two models don't share.
|
|
8
|
+
class FilterView
|
|
9
|
+
TEXT = SIDView::TEXT
|
|
10
|
+
BRIGHT = SIDView::BRIGHT
|
|
11
|
+
DIM = SIDView::DIM
|
|
12
|
+
BOX = SIDView::BOX
|
|
13
|
+
|
|
14
|
+
MODES = ["LP", "BP", "HP", "3 OFF"].freeze
|
|
15
|
+
|
|
16
|
+
LEFT = SIDView::LEFT
|
|
17
|
+
CURVE_LEFT = 224
|
|
18
|
+
WIDTH = SIDView::WAVE_WIDTH
|
|
19
|
+
HEIGHT = SIDView::PANEL_HEIGHT
|
|
20
|
+
|
|
21
|
+
# The lowest and highest frequencies on the curve's axis.
|
|
22
|
+
LOW_HZ = 30.0
|
|
23
|
+
HIGH_HZ = 16_000.0
|
|
24
|
+
|
|
25
|
+
def initialize(painter)
|
|
26
|
+
@painter = painter
|
|
27
|
+
@curve = Array.new(WIDTH + 1, 0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def draw(history, frame, top, model)
|
|
31
|
+
cutoff = (history.register(frame, 0x15) & 0x07) | (history.register(frame, 0x16) << 3)
|
|
32
|
+
resonance = history.register(frame, 0x17) >> 4
|
|
33
|
+
mode_volume = history.register(frame, 0x18)
|
|
34
|
+
hertz = SID::Filter::W0.fetch(model)[cutoff] / (2 * Math::PI * SID::Filter::SCALE)
|
|
35
|
+
paint = @painter
|
|
36
|
+
paint.text(LEFT, top, "FILTER", BRIGHT)
|
|
37
|
+
x = LEFT + 64
|
|
38
|
+
MODES.each_with_index do |name, bit|
|
|
39
|
+
x += paint.text(x, top, name, mode_volume[bit + 4] == 1 ? BRIGHT : DIM) + 8
|
|
40
|
+
end
|
|
41
|
+
paint.text(LEFT, top + 16, format("CUTOFF $%<cutoff>03X", cutoff:), TEXT)
|
|
42
|
+
paint.text(LEFT, top + 28, format(" %<hertz>d HZ", hertz: hertz.round), TEXT)
|
|
43
|
+
paint.text(LEFT, top + 40, format("RES %<res>X VOL %<vol>X", res: resonance, vol: mode_volume & 0x0f), TEXT)
|
|
44
|
+
draw_curve(hertz, resonance, mode_volume, top)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
# The gain for the modes selected on a logarithmic axis, with the
|
|
50
|
+
# cutoff marked.
|
|
51
|
+
def draw_curve(cutoff_hertz, resonance, mode_volume, top)
|
|
52
|
+
paint = @painter
|
|
53
|
+
paint.box(CURVE_LEFT, top, WIDTH, HEIGHT, BOX)
|
|
54
|
+
return paint.text(CURVE_LEFT + 8, top + 8, "NO MODE", DIM) if mode_volume.nobits?(0x70)
|
|
55
|
+
|
|
56
|
+
quality = 0.707 + (resonance / 15.0)
|
|
57
|
+
cutoff = [cutoff_hertz, 1.0].max
|
|
58
|
+
points = @curve
|
|
59
|
+
x = 0
|
|
60
|
+
while x <= WIDTH
|
|
61
|
+
hertz = LOW_HZ * ((HIGH_HZ / LOW_HZ)**(x.to_f / WIDTH))
|
|
62
|
+
decibels = gain(hertz / cutoff, quality, mode_volume).clamp(-36.0, 12.0)
|
|
63
|
+
points[x] = Painter.point(CURVE_LEFT + x, top + 8 + (((12.0 - decibels) * (HEIGHT - 12)) / 48.0).round)
|
|
64
|
+
x += 1
|
|
65
|
+
end
|
|
66
|
+
paint.polyline(points, BRIGHT)
|
|
67
|
+
marker = (WIDTH * Math.log(cutoff / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ)).round
|
|
68
|
+
paint.box(CURVE_LEFT + marker, top, 1, HEIGHT, DIM) if marker.between?(0, WIDTH)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# The gain in decibels of a two-pole state-variable filter at `ratio`
|
|
72
|
+
# of its cutoff, summing the low-, band- and high-pass outputs chosen.
|
|
73
|
+
def gain(ratio, quality, mode_volume)
|
|
74
|
+
real = 1.0 - (ratio * ratio)
|
|
75
|
+
imaginary = ratio / quality
|
|
76
|
+
denominator = (real * real) + (imaginary * imaginary)
|
|
77
|
+
out_real = 0.0
|
|
78
|
+
out_imaginary = 0.0
|
|
79
|
+
if mode_volume.anybits?(0x10)
|
|
80
|
+
out_real += real / denominator
|
|
81
|
+
out_imaginary -= imaginary / denominator
|
|
82
|
+
end
|
|
83
|
+
if mode_volume.anybits?(0x20)
|
|
84
|
+
out_real += ratio * imaginary / denominator
|
|
85
|
+
out_imaginary += ratio * real / denominator
|
|
86
|
+
end
|
|
87
|
+
if mode_volume.anybits?(0x40)
|
|
88
|
+
out_real -= ratio * ratio * real / denominator
|
|
89
|
+
out_imaginary += ratio * ratio * imaginary / denominator
|
|
90
|
+
end
|
|
91
|
+
magnitude = Math.sqrt((out_real * out_real) + (out_imaginary * out_imaginary))
|
|
92
|
+
20.0 * Math.log([magnitude, 1.0e-6].max) / Math.log(10)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|