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
data/lib/badline/audio/cli.rb
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Runs `--headless
|
|
6
|
-
# have parsed:
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Runs `--headless`, `--audio-out` or `sid`, in either build, once its
|
|
6
|
+
# options have parsed: queues the tunes, each with its subtune and length,
|
|
7
|
+
# then plays them or renders the one to a file. Played on a terminal,
|
|
8
|
+
# the console lets the listener step through the queue.
|
|
9
9
|
class CLI
|
|
10
10
|
class Error < StandardError; end
|
|
11
11
|
|
|
12
|
-
# Plays or renders the
|
|
12
|
+
# Plays or renders the tunes as #run does, reports an error on stderr
|
|
13
13
|
# under the program's name, and returns the exit status.
|
|
14
14
|
def self.run(options, sink:, console:)
|
|
15
15
|
new(options, sink:, console:).run
|
|
@@ -33,55 +33,84 @@ module Badline
|
|
|
33
33
|
@input = input
|
|
34
34
|
@sink = sink
|
|
35
35
|
@console = console
|
|
36
|
-
@
|
|
36
|
+
@announced = nil
|
|
37
|
+
@terminal = nil
|
|
38
|
+
@stil_lists = {}
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def run
|
|
40
42
|
@options.render? ? render : play
|
|
41
|
-
rescue Renderer::UnknownFormatError
|
|
43
|
+
rescue Renderer::UnknownFormatError => e
|
|
42
44
|
raise Error, e.message
|
|
43
45
|
end
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
# The tunes to play, in the order given. --subtune picks the first
|
|
48
|
+
# one's subtune.
|
|
49
|
+
def queue
|
|
50
|
+
@queue ||= begin
|
|
51
|
+
paths = @options.tune_paths
|
|
52
|
+
queue = Media::Queue.new(entries(paths, @options.subtune), all_parts: @options.all_subtunes?,
|
|
53
|
+
build: ->(dropped) { entries(dropped) })
|
|
54
|
+
raise Error, "no .sid tunes in #{paths.join(', ')}" if queue.empty? && !paths.empty?
|
|
50
55
|
|
|
51
|
-
|
|
52
|
-
@song ||= (@options.song || tune.start_song).tap do |song|
|
|
53
|
-
raise Error, "no song #{song}: the tune has #{tune.songs}" if song > tune.songs
|
|
56
|
+
queue
|
|
54
57
|
end
|
|
55
58
|
end
|
|
56
59
|
|
|
57
|
-
def
|
|
60
|
+
def tune = checked(queue.entry).tune
|
|
58
61
|
|
|
59
|
-
def
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
def subtune = checked(queue.entry).part
|
|
63
|
+
|
|
64
|
+
def seconds = length(subtune)
|
|
65
|
+
|
|
66
|
+
def length(subtune) = queue.entry.length(subtune)
|
|
67
|
+
|
|
68
|
+
def silence(subtune) = queue.entry.silence(subtune)
|
|
62
69
|
|
|
63
|
-
def
|
|
70
|
+
def sid_models = queue.entry.sid_models
|
|
64
71
|
|
|
65
|
-
def interactive? = @options.tui? && @input.tty? && @out.tty?
|
|
72
|
+
def interactive? = @options.player_window? || (@options.tui? && @input.tty? && @out.tty?)
|
|
66
73
|
|
|
67
74
|
private
|
|
68
75
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
76
|
+
# The tunes in the paths, the files themselves and the .sid files
|
|
77
|
+
# below the directories, the first starting on `subtune` if given.
|
|
78
|
+
def entries(paths, subtune = nil)
|
|
79
|
+
tunes = []
|
|
80
|
+
Media::Queue.files(paths, ".sid").each do |path|
|
|
81
|
+
tunes << QueuedTune.new(path, @options, subtune: tunes.empty? ? subtune : nil)
|
|
72
82
|
end
|
|
83
|
+
tunes
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# A tune on its own has to play, where one of many is skipped.
|
|
87
|
+
def checked(entry)
|
|
88
|
+
raise Error, "#{entry.path}: #{entry.error}" unless entry.error.empty?
|
|
89
|
+
|
|
90
|
+
entry
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def renderer(entry, subtune, rate)
|
|
94
|
+
renderer = Renderer.new(entry.tune, seconds: entry.length(subtune), subtune:, rate:,
|
|
95
|
+
sid_models: entry.sid_models)
|
|
96
|
+
renderer.filter_chunk = @options.filter_chunk if @options.filter_chunk
|
|
97
|
+
renderer.silence = entry.silence(subtune) if entry.silence(subtune)
|
|
98
|
+
renderer
|
|
73
99
|
end
|
|
74
100
|
|
|
75
101
|
def render
|
|
76
|
-
|
|
77
|
-
@
|
|
78
|
-
@out.puts
|
|
102
|
+
entry = checked(queue.entry)
|
|
103
|
+
renderer = renderer(entry, subtune, @options.rate)
|
|
104
|
+
@out.puts describe(entry, subtune)
|
|
105
|
+
@out.puts "Rendering #{seconds}s for the #{entry.model_name} to #{@options.audio_out} " \
|
|
106
|
+
"at #{@options.rate} Hz..."
|
|
79
107
|
started = now
|
|
80
|
-
renderer.render(@options.audio_out) { |done| progress(done) }
|
|
81
|
-
report(now - started)
|
|
108
|
+
renderer.render(@options.audio_out) { |done| progress(done, seconds) }
|
|
109
|
+
report(renderer.rendered, now - started)
|
|
82
110
|
end
|
|
83
111
|
|
|
84
112
|
def play
|
|
113
|
+
checked(queue.entry) if queue.size == 1
|
|
85
114
|
sink = open_sink
|
|
86
115
|
interactive? ? play_interactively(sink) : play_plainly(sink)
|
|
87
116
|
ensure
|
|
@@ -89,23 +118,85 @@ module Badline
|
|
|
89
118
|
end
|
|
90
119
|
|
|
91
120
|
def play_plainly(sink)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
121
|
+
loop do
|
|
122
|
+
result = play_subtune(sink, queue.entry, queue.part)
|
|
123
|
+
break unless %i[finished skipped].include?(result) && queue.advance
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def play_subtune(sink, entry, subtune)
|
|
128
|
+
return skip(entry) unless entry.error.empty?
|
|
129
|
+
|
|
130
|
+
announce(entry, subtune)
|
|
131
|
+
subtune_comments(entry, subtune).each { |line| @out.puts line }
|
|
132
|
+
length = entry.length(subtune)
|
|
133
|
+
@out.puts "Playing #{length}s on the #{entry.model_name} at #{sink.rate} Hz. Ctrl-C stops."
|
|
134
|
+
@warned = false
|
|
135
|
+
playback = Playback.new(sink, on_underrun: -> { warn_below_real_time })
|
|
136
|
+
renderer = renderer(entry, subtune, sink.rate)
|
|
137
|
+
result = playback.play(renderer) { |played| progress(played, length) }
|
|
138
|
+
progress(renderer.rendered, length) if result == :finished
|
|
97
139
|
@out.print "\n" unless @options.quiet?
|
|
98
140
|
@out.puts(result == :finished ? "Done." : "Stopped.")
|
|
141
|
+
result
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def warn_below_real_time
|
|
145
|
+
return if @warned
|
|
146
|
+
|
|
147
|
+
@warned = true
|
|
148
|
+
@out.puts "\rRunning below real time, so it will stutter."
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def skip(entry)
|
|
152
|
+
@out.puts "Skipping #{entry.path}: #{entry.error}"
|
|
153
|
+
:skipped
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def announce(entry, subtune)
|
|
157
|
+
@out.puts describe(entry, subtune)
|
|
158
|
+
return if entry.equal?(@announced)
|
|
159
|
+
|
|
160
|
+
@announced&.release
|
|
161
|
+
@announced = entry
|
|
162
|
+
comments(entry).each { |line| @out.puts line }
|
|
99
163
|
end
|
|
100
164
|
|
|
101
165
|
def play_interactively(sink)
|
|
102
|
-
|
|
103
|
-
jukebox = Jukebox.new(sink,
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
166
|
+
@terminal = @console.call(input: @input, output: @out)
|
|
167
|
+
jukebox = Jukebox.new(sink, @terminal, queue:,
|
|
168
|
+
renderer: ->(entry, subtune, rate) { playable(entry, subtune, rate) },
|
|
169
|
+
length: ->(entry, subtune) { entry.length(subtune) })
|
|
170
|
+
@terminal.session { jukebox.run }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# The renderer for the subtune, or nil for a tune the jukebox skips.
|
|
174
|
+
def playable(entry, subtune, rate)
|
|
175
|
+
introduce(entry, rate) unless entry.equal?(@announced)
|
|
176
|
+
return nil unless entry.error.empty?
|
|
177
|
+
|
|
178
|
+
comments = subtune_comments(entry, subtune)
|
|
179
|
+
@terminal.announce(comments) unless comments.empty?
|
|
180
|
+
renderer = renderer(entry, subtune, rate)
|
|
181
|
+
found = stil(entry)
|
|
182
|
+
@terminal.stil(found ? found.fields : [], subtune, found ? found.subtune(subtune) : [])
|
|
183
|
+
@terminal.playing(renderer)
|
|
184
|
+
renderer
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Shows the tune's header above the status line as it starts, below
|
|
188
|
+
# the keys the first time.
|
|
189
|
+
def introduce(entry, rate)
|
|
190
|
+
@announced&.release
|
|
191
|
+
lines = introduction(entry, rate)
|
|
192
|
+
@announced.nil? ? @terminal.header(lines) : @terminal.announce(lines)
|
|
193
|
+
@announced = entry
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def introduction(entry, rate)
|
|
197
|
+
return ["Skipping #{entry.path}: #{entry.error}"] unless entry.error.empty?
|
|
198
|
+
|
|
199
|
+
entry.header + ["#{entry.model_name} at #{rate} Hz"] + comments(entry)
|
|
109
200
|
end
|
|
110
201
|
|
|
111
202
|
def open_sink
|
|
@@ -114,36 +205,40 @@ module Badline
|
|
|
114
205
|
raise Error, "can't open the audio device: #{e.message}"
|
|
115
206
|
end
|
|
116
207
|
|
|
117
|
-
def describe
|
|
118
|
-
header =
|
|
119
|
-
header.empty? ? "
|
|
208
|
+
def describe(entry, subtune)
|
|
209
|
+
header = entry.header.join(" / ")
|
|
210
|
+
header.empty? ? "subtune #{subtune}" : "#{header} (subtune #{subtune})"
|
|
120
211
|
end
|
|
121
212
|
|
|
122
|
-
#
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return @songlengths if @songlengths_read
|
|
213
|
+
# The tune's entry in HVSC's STIL, as STIL.txt lays it out.
|
|
214
|
+
def comments(entry)
|
|
215
|
+
found = stil(entry)
|
|
216
|
+
found ? found.fields.flat_map(&:lines) : []
|
|
217
|
+
end
|
|
128
218
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
219
|
+
def subtune_comments(entry, subtune)
|
|
220
|
+
found = stil(entry)
|
|
221
|
+
lines = found ? found.subtune(subtune).flat_map(&:lines) : []
|
|
222
|
+
lines.empty? ? lines : ["(##{subtune})"] + lines
|
|
132
223
|
end
|
|
133
224
|
|
|
134
|
-
def
|
|
225
|
+
def stil(entry)
|
|
226
|
+
list = Storage::STIL.locate(entry.path)
|
|
227
|
+
hvsc_path = list && Storage::HVSC.path(entry.path, list)
|
|
228
|
+
hvsc_path && (@stil_lists[list] ||= Storage::STIL.new(list)).entry(hvsc_path)
|
|
229
|
+
end
|
|
135
230
|
|
|
136
|
-
def progress(done)
|
|
231
|
+
def progress(done, total)
|
|
137
232
|
return if @options.quiet?
|
|
138
233
|
|
|
139
|
-
@out.print format("\r%<done>6.1fs / %<total>.1fs", done:, total:
|
|
234
|
+
@out.print format("\r%<done>6.1fs / %<total>.1fs", done:, total:)
|
|
140
235
|
@out.flush
|
|
141
236
|
end
|
|
142
237
|
|
|
143
|
-
def report(elapsed)
|
|
238
|
+
def report(rendered, elapsed)
|
|
144
239
|
@out.print "\r" unless @options.quiet?
|
|
145
240
|
@out.puts format("Wrote %<path>s in %<elapsed>.1fs (%<speed>.2fx real time).",
|
|
146
|
-
path: @options.audio_out, elapsed:, speed:
|
|
241
|
+
path: @options.audio_out, elapsed:, speed: rendered / elapsed)
|
|
147
242
|
end
|
|
148
243
|
|
|
149
244
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -2,53 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Plays a
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Plays a Media::Queue of tunes interactively: the console's keys step
|
|
6
|
+
# between tunes and between a tune's subtunes, seek within the subtune, pause,
|
|
7
|
+
# turn the queue's shuffle, loop and all-subtunes modes on and off, and
|
|
8
|
+
# quit, and the status line follows along. Seeking runs the subtune
|
|
9
|
+
# silently up to where it was asked to go: on from where it is to go
|
|
10
|
+
# forward, and from the start again to go back. A subtune that plays to
|
|
11
|
+
# its end moves on as Media::Queue#advance says. It stops at the end of
|
|
12
|
+
# the queue, on q, or on Ctrl-C.
|
|
9
13
|
#
|
|
10
|
-
# `renderer` builds the renderer for
|
|
11
|
-
#
|
|
14
|
+
# `renderer` builds the renderer for an entry's subtune at the sink's
|
|
15
|
+
# rate, or returns nil for an entry that can't play, which is skipped
|
|
16
|
+
# in the direction the queue was stepping. `length` gives an entry's
|
|
17
|
+
# subtune's length in seconds.
|
|
12
18
|
class Jukebox
|
|
13
|
-
|
|
19
|
+
SEEK_STEP = 10.0
|
|
20
|
+
|
|
21
|
+
# How long a wait on an empty queue lasts before it checks again.
|
|
22
|
+
IDLE = 0.05
|
|
23
|
+
|
|
24
|
+
# How long the status line says the player fell below real time after
|
|
25
|
+
# the last time it did.
|
|
26
|
+
BELOW_NOTE = 3.0
|
|
27
|
+
|
|
28
|
+
def initialize(sink, console, queue:, renderer:, length:)
|
|
14
29
|
@sink = sink
|
|
15
30
|
@console = console
|
|
16
|
-
@
|
|
31
|
+
@queue = queue
|
|
17
32
|
@renderer = renderer
|
|
18
33
|
@length = length
|
|
19
|
-
@
|
|
34
|
+
@backward = false
|
|
35
|
+
@below_at = -BELOW_NOTE
|
|
36
|
+
@from = 0.0
|
|
20
37
|
end
|
|
21
38
|
|
|
22
|
-
# Returns the result of the last
|
|
23
|
-
|
|
39
|
+
# Returns the result of the last subtune's Playback#play, or :unplayable
|
|
40
|
+
# when the last entry reached couldn't play.
|
|
41
|
+
def run
|
|
42
|
+
skipped = 0
|
|
24
43
|
loop do
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
return :stopped unless tunes_to_play?
|
|
45
|
+
|
|
46
|
+
@moved = false
|
|
47
|
+
@quit = false
|
|
48
|
+
result = play(@queue.entry, @queue.part)
|
|
49
|
+
return result if @quit
|
|
50
|
+
next if @moved
|
|
51
|
+
|
|
52
|
+
skipped = result == :unplayable ? skipped + 1 : 0
|
|
53
|
+
return result if skipped >= @queue.size || !move_on(result)
|
|
29
54
|
end
|
|
30
55
|
end
|
|
31
56
|
|
|
32
57
|
private
|
|
33
58
|
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
# Waits on the console while the queue is empty, as a window's is
|
|
60
|
+
# until files are dropped on it. Returns false when asked to quit.
|
|
61
|
+
def tunes_to_play?
|
|
62
|
+
while @queue.empty?
|
|
63
|
+
actions = @console.wait(IDLE)
|
|
64
|
+
return false if actions.include?(:quit)
|
|
65
|
+
|
|
66
|
+
@queue.add(@console.dropped) if actions.include?(:drop)
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move_on(result)
|
|
72
|
+
return @queue.advance if result == :finished
|
|
73
|
+
return false unless result == :unplayable
|
|
74
|
+
return @queue.advance unless @backward
|
|
75
|
+
|
|
76
|
+
@queue.previous_entry || @queue.advance
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def play(entry, subtune)
|
|
80
|
+
@entry = entry
|
|
81
|
+
@subtune = subtune
|
|
36
82
|
@elapsed = 0.0
|
|
83
|
+
renderer = @renderer.call(entry, subtune, @sink.rate)
|
|
84
|
+
return :unplayable if renderer.nil?
|
|
85
|
+
|
|
86
|
+
renderer.from = @from
|
|
87
|
+
@streaming = renderer
|
|
88
|
+
@from = 0.0
|
|
89
|
+
@backward = false
|
|
37
90
|
@playback = Playback.new(@sink, sleeper: ->(seconds) { react(@console.wait(seconds)) },
|
|
38
|
-
on_underrun: -> { @
|
|
91
|
+
on_underrun: -> { @below_at = now })
|
|
39
92
|
draw
|
|
40
|
-
@playback.play(
|
|
93
|
+
@playback.play(renderer) do |played|
|
|
41
94
|
@elapsed = played
|
|
42
95
|
react(@console.wait(0))
|
|
43
|
-
draw
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def following(song, result)
|
|
48
|
-
case @action
|
|
49
|
-
when :next then song + 1
|
|
50
|
-
when :previous then song - 1
|
|
51
|
-
else song + 1 if result == :finished && song < @songs
|
|
96
|
+
draw unless @moved
|
|
52
97
|
end
|
|
53
98
|
end
|
|
54
99
|
|
|
@@ -56,29 +101,69 @@ module Badline
|
|
|
56
101
|
actions.each do |action|
|
|
57
102
|
case action
|
|
58
103
|
when :pause then toggle_pause
|
|
59
|
-
when :
|
|
60
|
-
when :
|
|
61
|
-
when :
|
|
104
|
+
when :shuffle then @queue.toggle_shuffle
|
|
105
|
+
when :loop then @queue.toggle_loop
|
|
106
|
+
when :all_subtunes then @queue.toggle_all_parts
|
|
107
|
+
when :quit then quit
|
|
108
|
+
when :drop then @queue.add(@console.dropped)
|
|
109
|
+
when :seek then seek(@console.seek_to)
|
|
110
|
+
when :forward then seek(@elapsed + SEEK_STEP)
|
|
111
|
+
when :back then seek(@elapsed - SEEK_STEP)
|
|
112
|
+
else skip if step(action)
|
|
62
113
|
end
|
|
63
114
|
end
|
|
115
|
+
draw unless actions.empty? || @moved
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def step(action)
|
|
119
|
+
@backward = action == :previous if %i[next previous].include?(action)
|
|
120
|
+
case action
|
|
121
|
+
when :next then @queue.next_entry
|
|
122
|
+
when :previous then @queue.previous_entry
|
|
123
|
+
when :next_subtune then @queue.next_part
|
|
124
|
+
when :previous_subtune then @queue.previous_part
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def seek(seconds)
|
|
129
|
+
target = seconds.clamp(0.0, [@length.call(@entry, @subtune) - 1.0, 0.0].max)
|
|
130
|
+
return @streaming.from = target if target > @elapsed
|
|
131
|
+
|
|
132
|
+
@from = target
|
|
133
|
+
skip
|
|
64
134
|
end
|
|
65
135
|
|
|
66
|
-
def skip
|
|
67
|
-
@
|
|
136
|
+
def skip
|
|
137
|
+
@moved = true
|
|
138
|
+
@playback.stop
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def quit
|
|
142
|
+
@quit = true
|
|
68
143
|
@playback.stop
|
|
69
144
|
end
|
|
70
145
|
|
|
71
146
|
def toggle_pause
|
|
72
147
|
@playback.paused? ? @playback.resume : @playback.pause
|
|
73
|
-
draw
|
|
74
148
|
end
|
|
75
149
|
|
|
76
150
|
def draw
|
|
151
|
+
@console.place(@queue.position, @queue.size)
|
|
152
|
+
@console.status(subtune: @subtune, subtunes: @entry.parts, elapsed: @elapsed,
|
|
153
|
+
length: @length.call(@entry, @subtune), notes:)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def notes
|
|
77
157
|
notes = []
|
|
78
158
|
notes << "paused" if @playback.paused?
|
|
79
|
-
notes << "
|
|
80
|
-
|
|
159
|
+
notes << "shuffle" if @queue.shuffle?
|
|
160
|
+
notes << "loop" if @queue.loop?
|
|
161
|
+
notes << "all subtunes" if @queue.all_parts?
|
|
162
|
+
notes << "below real time" if now - @below_at < BELOW_NOTE
|
|
163
|
+
notes
|
|
81
164
|
end
|
|
165
|
+
|
|
166
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
167
|
end
|
|
83
168
|
end
|
|
84
169
|
end
|
|
@@ -14,10 +14,13 @@ module Badline
|
|
|
14
14
|
# Boot, plus room for the keyboard buffer to type the SYS.
|
|
15
15
|
START_LIMIT = 15_000_000
|
|
16
16
|
|
|
17
|
-
def initialize(tune,
|
|
17
|
+
def initialize(tune, subtune: nil, sid_models: tune.sid_models)
|
|
18
18
|
@tune = tune
|
|
19
|
-
@
|
|
20
|
-
@computer = Computer.new(sid_model:)
|
|
19
|
+
@subtune = subtune || tune.start_subtune
|
|
20
|
+
@computer = Computer.new(sid_model: sid_models[0])
|
|
21
|
+
@stereo = Stereo.new(@computer.address_bus, tune, sid_models)
|
|
22
|
+
@extras = @stereo.extras
|
|
23
|
+
@multi = !@extras.empty?
|
|
21
24
|
region = @computer.region
|
|
22
25
|
@frame_cycles = region.cycles_per_line * region.lines_per_frame
|
|
23
26
|
@injected = false
|
|
@@ -27,28 +30,40 @@ module Badline
|
|
|
27
30
|
def start
|
|
28
31
|
@computer.on_init { inject }
|
|
29
32
|
@computer.cpu.install_trap(@tune.entry_address) { begin_playing }
|
|
30
|
-
|
|
33
|
+
step until @started || @computer.cycles > START_LIMIT
|
|
31
34
|
end
|
|
32
35
|
|
|
36
|
+
attr_reader :stereo
|
|
37
|
+
|
|
33
38
|
def sid = @computer.sid
|
|
34
39
|
|
|
35
40
|
def clock_hz = @computer.region.clock_hz
|
|
36
41
|
|
|
37
42
|
# Advances one frame of the machine, or `budget` cycles if that is
|
|
38
|
-
# shorter, then yields whatever the
|
|
39
|
-
# cycles advanced.
|
|
43
|
+
# shorter, then yields whatever the SIDs recorded over it, in stereo.
|
|
44
|
+
# Returns the cycles advanced.
|
|
40
45
|
def frame(budget, &)
|
|
41
46
|
cycles = [budget, @frame_cycles].min
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
if @multi
|
|
48
|
+
cycles.times { step }
|
|
49
|
+
else
|
|
50
|
+
cycles.times { @computer.cycle! }
|
|
51
|
+
end
|
|
52
|
+
@stereo.mix(&)
|
|
44
53
|
cycles
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
private
|
|
48
57
|
|
|
58
|
+
# The extra SIDs clock ahead of the CPU, as the machine's own does.
|
|
59
|
+
def step
|
|
60
|
+
@extras.each(&:cycle!) if @multi
|
|
61
|
+
@computer.cycle!
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
def inject
|
|
50
65
|
@computer.ram.write(@tune.load_address, @tune.data)
|
|
51
|
-
@tune.boot_memory(
|
|
66
|
+
@tune.boot_memory(subtune: @subtune).each { |address, bytes| @computer.ram.write(address, bytes) }
|
|
52
67
|
@computer.type_text(@tune.boot_command)
|
|
53
68
|
@injected = true
|
|
54
69
|
end
|
|
@@ -56,7 +71,7 @@ module Badline
|
|
|
56
71
|
def begin_playing
|
|
57
72
|
return if @started || !@injected
|
|
58
73
|
|
|
59
|
-
@
|
|
74
|
+
@stereo.synthesize!
|
|
60
75
|
@started = true
|
|
61
76
|
end
|
|
62
77
|
end
|
|
@@ -2,24 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Streams
|
|
6
|
-
# patching the header's length fields once the stream ends.
|
|
5
|
+
# Streams signed 16-bit samples into a container, buffering them and
|
|
6
|
+
# patching the header's length fields once the stream ends. With more
|
|
7
|
+
# than one channel the samples come interleaved, a frame at a time.
|
|
7
8
|
class PCMWriter
|
|
8
9
|
BUFFER = 4096
|
|
9
10
|
|
|
10
|
-
attr_reader :rate, :samples
|
|
11
|
+
attr_reader :rate, :channels, :samples
|
|
11
12
|
|
|
12
|
-
def self.open(path, rate:)
|
|
13
|
+
def self.open(path, rate:, channels: 1)
|
|
13
14
|
File.open(path, "wb") do |io|
|
|
14
|
-
writer = new(io, rate:)
|
|
15
|
+
writer = new(io, rate:, channels:)
|
|
15
16
|
yield writer
|
|
16
17
|
writer.finish
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
def initialize(io, rate:)
|
|
21
|
+
def initialize(io, rate:, channels: 1)
|
|
21
22
|
@io = io
|
|
22
23
|
@rate = rate
|
|
24
|
+
@channels = channels
|
|
23
25
|
@samples = 0
|
|
24
26
|
@buffer = []
|
|
25
27
|
@io.write(header)
|
|
@@ -48,6 +50,8 @@ module Badline
|
|
|
48
50
|
|
|
49
51
|
def data_size = @samples * 2
|
|
50
52
|
|
|
53
|
+
def frames = @samples / @channels
|
|
54
|
+
|
|
51
55
|
def write_at(offset, bytes)
|
|
52
56
|
@io.seek(offset)
|
|
53
57
|
@io.write(bytes)
|
|
@@ -7,10 +7,11 @@ module Badline
|
|
|
7
7
|
# queue has drained below `ahead` seconds, and the device only starts
|
|
8
8
|
# once the queue first reaches it.
|
|
9
9
|
#
|
|
10
|
-
# A sink answers #rate, #queue(samples),
|
|
11
|
-
# and #clear; whoever
|
|
10
|
+
# A sink answers #rate, #queue(samples), which takes stereo samples
|
|
11
|
+
# interleaved, #queued_seconds, #start, #pause and #clear; whoever
|
|
12
|
+
# opened it closes it. An emulator running below
|
|
12
13
|
# real time lets the queue run dry and the audio stutters; `on_underrun`
|
|
13
|
-
# hears
|
|
14
|
+
# hears of each time it does.
|
|
14
15
|
class Playback
|
|
15
16
|
# What a sink raises when the audio device won't open.
|
|
16
17
|
class DeviceError < StandardError; end
|
|
@@ -73,6 +74,8 @@ module Badline
|
|
|
73
74
|
private
|
|
74
75
|
|
|
75
76
|
def enqueue(samples)
|
|
77
|
+
return if samples.empty?
|
|
78
|
+
|
|
76
79
|
wait_below(@ahead)
|
|
77
80
|
underrun! if @started && @sink.queued_seconds.zero?
|
|
78
81
|
@sink.queue(samples)
|
|
@@ -92,8 +95,6 @@ module Badline
|
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def underrun!
|
|
95
|
-
return if @underrun
|
|
96
|
-
|
|
97
98
|
@underrun = true
|
|
98
99
|
@on_underrun&.call
|
|
99
100
|
end
|