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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +125 -46
  4. data/exe/badline-ruby +9 -1
  5. data/lib/badline/address_bus/extra_sids.rb +30 -0
  6. data/lib/badline/address_bus/sid_slots.rb +42 -0
  7. data/lib/badline/address_bus.rb +5 -0
  8. data/lib/badline/audio/aiff.rb +2 -2
  9. data/lib/badline/audio/bare_player.rb +23 -12
  10. data/lib/badline/audio/cli.rb +153 -58
  11. data/lib/badline/audio/jukebox.rb +121 -36
  12. data/lib/badline/audio/machine_player.rb +25 -10
  13. data/lib/badline/audio/pcm_writer.rb +10 -6
  14. data/lib/badline/audio/playback.rb +6 -5
  15. data/lib/badline/audio/queued_tune.rb +89 -0
  16. data/lib/badline/audio/renderer.rb +57 -10
  17. data/lib/badline/audio/stereo.rb +97 -0
  18. data/lib/badline/audio/terminal.rb +39 -6
  19. data/lib/badline/audio/wav.rb +1 -1
  20. data/lib/badline/audio.rb +2 -0
  21. data/lib/badline/frontend/audio_sink.rb +6 -5
  22. data/lib/badline/frontend/boot.rb +1 -1
  23. data/lib/badline/frontend/buttons.rb +90 -0
  24. data/lib/badline/frontend/filter_view.rb +96 -0
  25. data/lib/badline/frontend/info_view.rb +120 -0
  26. data/lib/badline/frontend/painter.rb +175 -0
  27. data/lib/badline/frontend/player_commands.rb +33 -0
  28. data/lib/badline/frontend/player_footer.rb +86 -0
  29. data/lib/badline/frontend/player_header.rb +60 -0
  30. data/lib/badline/frontend/player_screen.rb +124 -0
  31. data/lib/badline/frontend/player_state.rb +67 -0
  32. data/lib/badline/frontend/player_window.rb +228 -0
  33. data/lib/badline/frontend/scope.rb +42 -0
  34. data/lib/badline/frontend/sid_history.rb +175 -0
  35. data/lib/badline/frontend/sid_view.rb +140 -0
  36. data/lib/badline/frontend/visualizer_view.rb +101 -0
  37. data/lib/badline/frontend.rb +14 -0
  38. data/lib/badline/media/queue.rb +158 -0
  39. data/lib/badline/media.rb +13 -8
  40. data/lib/badline/options/help.rb +34 -5
  41. data/lib/badline/options/option.rb +1 -1
  42. data/lib/badline/options/table.rb +4 -2
  43. data/lib/badline/options/validation.rb +54 -0
  44. data/lib/badline/options.rb +62 -43
  45. data/lib/badline/sdl.rb +16 -6
  46. data/lib/badline/sid/catch_up.rb +11 -1
  47. data/lib/badline/sid/filter.rb +9 -0
  48. data/lib/badline/sid/voice.rb +6 -0
  49. data/lib/badline/sid/waveform.rb +20 -5
  50. data/lib/badline/sid.rb +35 -3
  51. data/lib/badline/storage/hvsc.rb +41 -0
  52. data/lib/badline/storage/sid_file.rb +45 -25
  53. data/lib/badline/storage/song_lengths.rb +6 -29
  54. data/lib/badline/storage/stil/credit.rb +50 -0
  55. data/lib/badline/storage/stil.rb +126 -0
  56. data/lib/badline/storage.rb +8 -0
  57. data/lib/badline/version.rb +1 -1
  58. metadata +24 -1
@@ -2,14 +2,14 @@
2
2
 
3
3
  module Badline
4
4
  module Audio
5
- # Runs `--headless` or `--audio-out`, in either build, once its options
6
- # have parsed: picks the song and its length, then plays it or renders
7
- # it to a file. Played on a terminal, the console lets the listener step
8
- # between songs.
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 tune as #run does, reports an error on stderr
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
- @lengths = {}
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, Storage::SIDFile::FormatError => e
43
+ rescue Renderer::UnknownFormatError => e
42
44
  raise Error, e.message
43
45
  end
44
46
 
45
- def tune
46
- @tune ||= Storage::SIDFile.new(@options.tune_path)
47
- rescue Storage::SIDFile::FormatError => e
48
- raise Error, "#{@options.tune_path}: #{e.message}"
49
- end
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
- def song
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 seconds = length(song)
60
+ def tune = checked(queue.entry).tune
58
61
 
59
- def length(song)
60
- @lengths[song] ||= @options.seconds || songlength(song) || @options.fallback_seconds
61
- end
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 sid_model = @options.sid_model || tune.sid_model
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
- def renderer(song, rate)
70
- Renderer.new(tune, seconds: length(song), song:, rate:, sid_model:).tap do |renderer|
71
- renderer.filter_chunk = @options.filter_chunk if @options.filter_chunk
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
- renderer = renderer(song, @options.rate)
77
- @out.puts describe
78
- @out.puts "Rendering #{seconds}s for the #{model_name} to #{@options.audio_out} at #{@options.rate} Hz..."
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
- @out.puts describe
93
- @out.puts "Playing #{seconds}s on the #{model_name} at #{sink.rate} Hz. Ctrl-C stops."
94
- playback = Playback.new(sink, on_underrun: -> { @out.puts "\rRunning below real time, so it will stutter." })
95
- result = playback.play(renderer(song, sink.rate)) { |played| progress(played) }
96
- progress(seconds) if result == :finished
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
- console = @console.call(input: @input, output: @out)
103
- jukebox = Jukebox.new(sink, console, songs: tune.songs, renderer: method(:renderer), length: method(:length))
104
- console.session do
105
- console.header([tune.name, tune.author, tune.released].reject(&:empty?) +
106
- ["#{model_name} at #{sink.rate} Hz"])
107
- jukebox.run(song)
108
- end
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 = [tune.name, tune.author, tune.released].reject(&:empty?).join(" / ")
119
- header.empty? ? "song #{song}" : "#{header} (song #{song})"
208
+ def describe(entry, subtune)
209
+ header = entry.header.join(" / ")
210
+ header.empty? ? "subtune #{subtune}" : "#{header} (subtune #{subtune})"
120
211
  end
121
212
 
122
- # HVSC's database is keyed by the tune's MD5 and lists one length per
123
- # song.
124
- def songlength(song) = songlengths&.at(song - 1)
125
-
126
- def songlengths
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
- @songlengths_read = true
130
- path = @options.songlengths || Storage::SongLengths.locate(@options.tune_path)
131
- @songlengths = path && Storage::SongLengths.new(path).lengths(tune.md5)
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 model_name = sid_model.to_s.delete_prefix("mos")
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: seconds)
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: seconds / elapsed)
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 tune's songs interactively: the console's keys step between
6
- # songs, pause and quit, and the status line follows along. A song that
7
- # plays to its end moves on to the next one. It stops after the last
8
- # song, on q, or on Ctrl-C.
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 a song at the sink's rate, and
11
- # `length` gives a song's length in seconds.
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
- def initialize(sink, console, songs:, renderer:, length:)
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
- @songs = songs
31
+ @queue = queue
17
32
  @renderer = renderer
18
33
  @length = length
19
- @below = false
34
+ @backward = false
35
+ @below_at = -BELOW_NOTE
36
+ @from = 0.0
20
37
  end
21
38
 
22
- # Returns the result of the last song's Playback#play.
23
- def run(song)
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
- @action = nil
26
- result = play(song)
27
- song = following(song, result)
28
- return result unless song
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
- def play(song)
35
- @song = song
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: -> { @below = true })
91
+ on_underrun: -> { @below_at = now })
39
92
  draw
40
- @playback.play(@renderer.call(song, @sink.rate)) do |played|
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 :next then skip(:next) if @song < @songs
60
- when :previous then skip(:previous) if @song > 1
61
- when :quit then skip(:quit)
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(action)
67
- @action = action
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 << "below real time" if @below
80
- @console.status(song: @song, songs: @songs, elapsed: @elapsed, length: @length.call(@song), notes:)
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, song: nil, sid_model: tune.sid_model)
17
+ def initialize(tune, subtune: nil, sid_models: tune.sid_models)
18
18
  @tune = tune
19
- @song = song || tune.start_song
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
- @computer.cycle! until @started || @computer.cycles > START_LIMIT
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 SID recorded over it. Returns 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
- cycles.times { @computer.cycle! }
43
- sid.drain_samples.each(&)
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(song: @song).each { |address, bytes| @computer.ram.write(address, bytes) }
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
- @computer.sid.synthesize!
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 mono signed 16-bit samples into a container, buffering them and
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), #queued_seconds, #start, #pause
11
- # and #clear; whoever opened it closes it. An emulator running below
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 about the first time.
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