badline 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +135 -42
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +28 -24
  7. data/lib/badline/address_bus/extra_sids.rb +30 -0
  8. data/lib/badline/address_bus/sid_slots.rb +42 -0
  9. data/lib/badline/address_bus.rb +15 -1
  10. data/lib/badline/audio/aiff.rb +2 -2
  11. data/lib/badline/audio/bare_player.rb +23 -12
  12. data/lib/badline/audio/cli.rb +167 -59
  13. data/lib/badline/audio/console.rb +2 -1
  14. data/lib/badline/audio/jukebox.rb +121 -36
  15. data/lib/badline/audio/machine_player.rb +25 -10
  16. data/lib/badline/audio/pcm_writer.rb +10 -6
  17. data/lib/badline/audio/playback.rb +6 -5
  18. data/lib/badline/audio/queued_tune.rb +89 -0
  19. data/lib/badline/audio/renderer.rb +57 -10
  20. data/lib/badline/audio/stereo.rb +97 -0
  21. data/lib/badline/audio/terminal.rb +39 -6
  22. data/lib/badline/audio/wav.rb +1 -1
  23. data/lib/badline/audio.rb +2 -3
  24. data/lib/badline/computer.rb +6 -3
  25. data/lib/badline/cpu/boundary.rb +54 -0
  26. data/lib/badline/cpu/core.rb +165 -0
  27. data/lib/badline/cpu.rb +3 -214
  28. data/lib/badline/drive1541/cpu.rb +29 -0
  29. data/lib/badline/drive1541.rb +1 -0
  30. data/lib/badline/ffi.rb +157 -0
  31. data/lib/badline/frontend/app.rb +225 -0
  32. data/lib/badline/frontend/audio_sink.rb +73 -0
  33. data/lib/badline/frontend/boot.rb +52 -0
  34. data/lib/badline/frontend/buttons.rb +90 -0
  35. data/lib/badline/frontend/controls.rb +163 -0
  36. data/lib/badline/frontend/drive_led.rb +51 -0
  37. data/lib/badline/frontend/filter_view.rb +96 -0
  38. data/lib/badline/frontend/frame_rate.rb +83 -0
  39. data/lib/badline/frontend/gamepads.rb +76 -0
  40. data/lib/badline/frontend/info_view.rb +120 -0
  41. data/lib/badline/frontend/keys.rb +48 -0
  42. data/lib/badline/frontend/pacer.rb +100 -0
  43. data/lib/badline/frontend/pad_port.rb +61 -0
  44. data/lib/badline/frontend/painter.rb +175 -0
  45. data/lib/badline/frontend/player_commands.rb +33 -0
  46. data/lib/badline/frontend/player_footer.rb +86 -0
  47. data/lib/badline/frontend/player_header.rb +60 -0
  48. data/lib/badline/frontend/player_screen.rb +124 -0
  49. data/lib/badline/frontend/player_state.rb +67 -0
  50. data/lib/badline/frontend/player_window.rb +228 -0
  51. data/lib/badline/frontend/scope.rb +42 -0
  52. data/lib/badline/frontend/screen.rb +65 -0
  53. data/lib/badline/frontend/screenshot.rb +22 -0
  54. data/lib/badline/frontend/sid_history.rb +175 -0
  55. data/lib/badline/frontend/sid_view.rb +140 -0
  56. data/lib/badline/frontend/snapshots.rb +60 -0
  57. data/lib/badline/frontend/sound.rb +153 -0
  58. data/lib/badline/frontend/timeline.rb +171 -0
  59. data/lib/badline/frontend/visualizer_view.rb +101 -0
  60. data/lib/badline/frontend.rb +32 -0
  61. data/lib/badline/media/queue.rb +158 -0
  62. data/lib/badline/media.rb +13 -8
  63. data/lib/badline/options/event.rb +94 -0
  64. data/lib/badline/options/help.rb +107 -0
  65. data/lib/badline/options/option.rb +29 -0
  66. data/lib/badline/options/table.rb +64 -0
  67. data/lib/badline/options/validation.rb +54 -0
  68. data/lib/badline/options.rb +204 -149
  69. data/lib/badline/reu/dma/saved_state.rb +50 -0
  70. data/lib/badline/reu/dma.rb +56 -47
  71. data/lib/badline/reu/saved_state.rb +2 -1
  72. data/lib/badline/reu.rb +19 -10
  73. data/lib/badline/sdl.rb +133 -51
  74. data/lib/badline/sid/catch_up.rb +11 -1
  75. data/lib/badline/sid/filter.rb +9 -0
  76. data/lib/badline/sid/voice.rb +6 -0
  77. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  78. data/lib/badline/sid/waveform.rb +22 -8
  79. data/lib/badline/sid.rb +35 -3
  80. data/lib/badline/snapshot/state.rb +1 -1
  81. data/lib/badline/storage/hvsc.rb +41 -0
  82. data/lib/badline/storage/sid_file.rb +45 -25
  83. data/lib/badline/storage/song_lengths.rb +6 -29
  84. data/lib/badline/storage/stil/credit.rb +50 -0
  85. data/lib/badline/storage/stil.rb +126 -0
  86. data/lib/badline/storage.rb +8 -0
  87. data/lib/badline/version.rb +1 -1
  88. data/lib/badline/vic/bank.rb +21 -0
  89. data/lib/badline/vic.rb +15 -6
  90. metadata +49 -19
  91. data/.claude/hooks/session-start.sh +0 -57
  92. data/.claude/settings.json +0 -14
  93. data/lib/badline/audio/sdl_sink.rb +0 -70
  94. data/lib/badline/audio/stream.rb +0 -123
  95. data/lib/badline/gui/application.rb +0 -243
  96. data/lib/badline/gui/drive_led_pane.rb +0 -29
  97. data/lib/badline/gui/gamepads.rb +0 -104
  98. data/lib/badline/gui/joy_map.rb +0 -27
  99. data/lib/badline/gui/key_map.rb +0 -39
  100. data/lib/badline/gui/palette.rb +0 -35
  101. data/lib/badline/gui/pane.rb +0 -36
  102. data/lib/badline/gui/screen_pane.rb +0 -46
  103. data/lib/badline/gui/snapshots.rb +0 -67
  104. data/lib/badline/gui/window.rb +0 -61
  105. data/lib/badline/gui.rb +0 -14
  106. data/lib/badline/sdl/events.rb +0 -49
  107. data/lib/badline/sdl/functions.rb +0 -74
  108. data/vendor/.gitkeep +0 -0
@@ -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
@@ -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. A PSID tune with a play address runs on the bare rig;
7
- # anything else drives its own interrupts and needs the whole machine.
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
- def initialize(tune, seconds:, song: nil, rate: DEFAULT_RATE, sid_model: tune.sid_model)
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
- @song = song
45
+ @subtune = subtune
26
46
  @rate = rate
27
- @sid_model = sid_model
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, song: @song, sid_model: @sid_model)
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.sid.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
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
- yield samples, (total - remaining).fdiv(player.clock_hz)
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