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
data/exe/badline-ruby CHANGED
@@ -26,39 +26,43 @@ RubyVM::YJIT.enable if options.jit? && jit_available
26
26
 
27
27
  require "badline"
28
28
 
29
+ # Quiets IO::Buffer's experimental warning.
30
+ Warning[:experimental] = false
31
+
32
+ def load_sdl
33
+ require "badline/ffi"
34
+ require "badline/frontend"
35
+ end
36
+
29
37
  if options.headless?
30
- begin
31
- Badline::Audio::CLI.new(options, sink: Badline::Audio::SDLSink.method(:new),
32
- console: Badline::Audio::Console.method(:new)).run
33
- rescue Badline::Audio::CLI::Error => e
34
- warn "badline-ruby: #{e.message}"
35
- exit 1
36
- rescue Interrupt
37
- exit 130
38
+ require "badline/audio/console"
39
+ console = if options.player_window?
40
+ lambda do |input:, output:|
41
+ load_sdl
42
+ Badline::Frontend::PlayerWindow.new(input:, output:)
43
+ end
44
+ else
45
+ Badline::Audio::Console.method(:new)
46
+ end
47
+ sink = lambda do |rate:, exact_rate:|
48
+ load_sdl
49
+ Badline::Frontend::AudioSink.new(rate:, exact_rate:)
50
+ rescue Badline::FFI::Error => e
51
+ raise Badline::Audio::Playback::DeviceError, e.message
38
52
  end
39
- exit
53
+ exit Badline::Audio::CLI.run(options, sink:, console:)
40
54
  end
41
55
 
42
56
  begin
43
- require "badline/gui"
44
- rescue Badline::SDL::Error => e
57
+ load_sdl
58
+ rescue Badline::FFI::Error => e
45
59
  warn "badline-ruby: can't open the window: #{e.message}. " \
46
60
  "Install SDL2 with 'brew install sdl2' or 'apt install libsdl2-2.0-0'."
47
61
  exit 1
48
62
  end
49
63
 
50
64
  begin
51
- application = Badline::GUI::Application.new(media_path: options.media_path, autostart: options.autostart?,
52
- song: options.song, sound: options.sound?, verbose: options.verbose?,
53
- machine: { sid_model: options.sid_model, reu: options.reu,
54
- true_drive: options.true_drive?, ntsc: options.ntsc? },
55
- disk: { read_only: options.read_only? })
56
- rescue Badline::Media::TrueDrive::Error, Badline::Storage::SIDFile::FormatError, Badline::Storage::T64::FormatError,
57
- Badline::Storage::TAP::FormatError, Badline::Storage::CRTFile::FormatError,
58
- Badline::Storage::G64Image::FormatError, Badline::Cartridge::UnsupportedTypeError,
59
- Badline::Snapshot::FormatError => e
60
- warn "badline-ruby: #{options.media_path}: #{e.message}"
61
- exit 1
65
+ exit Badline::Frontend.run(options)
66
+ rescue Interrupt
67
+ exit 130
62
68
  end
63
-
64
- application.run
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class AddressBus
5
+ # The second and third SIDs the SID player fits for a tune written for
6
+ # them, each over its 32 bytes of the I/O area (SIDSlots). The SID
7
+ # player's machine has nothing else in I/O 1 and 2, so the rest of a
8
+ # page there reads the open bus.
9
+ module ExtraSIDs
10
+ # Fits a SID at the address it was built at, where it takes its 32
11
+ # bytes over from whatever maps there.
12
+ def add_sid(sid)
13
+ page = sid.start >> 8
14
+ slots = @sid_slots.find { |candidate| candidate.page == page }
15
+ if slots.nil?
16
+ slots = SIDSlots.new(page, @sid, @open_bus)
17
+ @sid_slots << slots
18
+ end
19
+ slots.place(sid)
20
+ update_overlays!
21
+ end
22
+
23
+ private
24
+
25
+ def map_extra_sids
26
+ @sid_slots.each { |slots| @read_pages[slots.page] = @write_pages[slots.page] = slots }
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ class AddressBus
5
+ # Stands in for a page of I/O that a second or third SID shares: each 32
6
+ # bytes of it go to the SID placed there, or else to the first SID's
7
+ # mirrors in `$d400-$d7ff`, and to the open bus in I/O 1 and 2.
8
+ class SIDSlots
9
+ attr_reader :page
10
+
11
+ def initialize(page, first, open_bus)
12
+ @page = page
13
+ @io = page >= 0xde
14
+ @first = first
15
+ @open_bus = open_bus
16
+ @sids = []
17
+ @slots = Array.new(8, -1)
18
+ end
19
+
20
+ def place(sid)
21
+ @slots[(sid.start >> 5) & 7] = @sids.size
22
+ @sids << sid
23
+ end
24
+
25
+ def peek(addr)
26
+ index = @slots[(addr >> 5) & 7]
27
+ return @sids[index].peek(addr) unless index.negative?
28
+
29
+ @io ? @open_bus.peek(addr) : @first.peek(addr)
30
+ end
31
+
32
+ def poke(addr, value)
33
+ index = @slots[(addr >> 5) & 7]
34
+ if !index.negative?
35
+ @sids[index].poke(addr, value)
36
+ elsif !@io
37
+ @first.poke(addr, value)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "badline/address_bus/saved_state"
4
+ require "badline/address_bus/sid_slots"
5
+ require "badline/address_bus/extra_sids"
4
6
  require "badline/address_bus/ultimax_pages"
5
7
 
6
8
  module Badline
@@ -32,6 +34,7 @@ module Badline
32
34
  class AddressBus
33
35
  include Addressable
34
36
  include UltimaxPages
37
+ include ExtraSIDs
35
38
 
36
39
  # I/O 1 and 2, and the Ultimax holes, with nothing on the bus. A read
37
40
  # picks up the byte the VIC fetched in the preceding phi1 half-cycle,
@@ -82,6 +85,7 @@ module Badline
82
85
  @control_ports.port_a_source = @cia1
83
86
  @cia1.on_port_b4_change { |high| @vic.lightpen_level(high) }
84
87
  @sid = SID.new(model: sid_model, pots: @control_ports)
88
+ @sid_slots = []
85
89
 
86
90
  @datasette = Datasette.new
87
91
  @datasette.on_flag { @cia1.flag! }
@@ -89,6 +93,7 @@ module Badline
89
93
 
90
94
  @color_ram = ColorMemory.new(@vic)
91
95
  @open_bus = OpenBus.new(@vic)
96
+ @cpu_off_bus = false
92
97
 
93
98
  @port_ddr = 0x00
94
99
  @port_out = 0x00
@@ -154,11 +159,19 @@ module Badline
154
159
  @ram.poke(addr, @vic.phi1_data)
155
160
  addr.zero? ? @port_ddr = value : @port_out = value
156
161
  update_port!
157
- else
162
+ elsif !@cpu_off_bus
158
163
  @write_pages[addr >> 8].poke(addr, value)
159
164
  end
160
165
  end
161
166
 
167
+ # Runs a CPU cycle with the CPU off the bus, as an REU's /DMA line
168
+ # holds it: a write it makes reaches nothing but its own port.
169
+ def cycle_cpu_off_bus(cpu)
170
+ @cpu_off_bus = true
171
+ cpu.cycle!
172
+ @cpu_off_bus = false
173
+ end
174
+
162
175
  def inspect
163
176
  "#<#{self.class.name} port=#{format('0x%02x', @io_port.value)} " \
164
177
  "cartridge=#{@cartridge ? @cartridge.class.name : 'none'} " \
@@ -243,6 +256,7 @@ module Badline
243
256
  @read_pages[0xd7] = @write_pages[0xd7] = @debug_register if @debug_register
244
257
  @read_pages[0xdf] = @write_pages[0xdf] = @reu if @reu
245
258
  map_cartridge_io if @cartridge
259
+ map_extra_sids unless @sid_slots.empty?
246
260
  end
247
261
 
248
262
  def map_cartridge_io
@@ -13,14 +13,14 @@ module Badline
13
13
 
14
14
  def header
15
15
  ["FORM", 0, "AIFF",
16
- "COMM", 18, 1, 0, 16].pack("a4Na4a4NnNn") +
16
+ "COMM", 18, channels, 0, 16].pack("a4Na4a4NnNn") +
17
17
  extended(rate) +
18
18
  ["SSND", 0, 0, 0].pack("a4NNN")
19
19
  end
20
20
 
21
21
  def patch
22
22
  write_at(4, [46 + data_size].pack("N"))
23
- write_at(22, [samples].pack("N"))
23
+ write_at(22, [frames].pack("N"))
24
24
  write_at(42, [8 + data_size].pack("N"))
25
25
  end
26
26
 
@@ -44,12 +44,12 @@ module Badline
44
44
  # The KERNAL's PAL/NTSC flag, 1 for PAL.
45
45
  VIDEO_STANDARD = 0x02a6
46
46
 
47
- attr_reader :sid
47
+ attr_reader :sid, :stereo
48
48
 
49
- def initialize(tune, song: nil, sid_model: tune.sid_model)
49
+ def initialize(tune, subtune: nil, sid_models: tune.sid_models)
50
50
  @tune = tune
51
- @song = (song || tune.start_song).clamp(1, tune.songs) - 1
52
- @bus = AddressBus.new(sid_model:)
51
+ @subtune = (subtune || tune.start_subtune).clamp(1, tune.subtunes) - 1
52
+ @bus = AddressBus.new(sid_model: sid_models[0])
53
53
  # The CPU port as the KERNAL leaves it, which a PSID tune expects.
54
54
  @bus.poke(0x00, 0x2f)
55
55
  @bus.poke(0x01, 0x37)
@@ -57,28 +57,38 @@ module Badline
57
57
  @bus.cia1.timer_a_latch = tune.ntsc? ? NTSC_KERNAL_TIMER : KERNAL_TIMER
58
58
  @cpu = CPU.new(@bus)
59
59
  @sid = @bus.sid
60
+ @stereo = Stereo.new(@bus, tune, sid_models)
61
+ @extras = @stereo.extras
62
+ @multi = !@extras.empty?
60
63
  @idle = false
61
64
  end
62
65
 
63
66
  def start
64
67
  @bus.ram.write(@tune.load_address, @tune.data)
65
68
  @bus.ram.write(stub_address, stub)
66
- @sid.synthesize!
69
+ @stereo.synthesize!
67
70
  @cpu.status.interrupt = true
68
71
  @cpu.program_counter = idle_address
69
72
  install_dispatch
70
- call(@tune.init_address, @song)
73
+ call(@tune.init_address, @subtune)
71
74
  settle
72
75
  end
73
76
 
74
77
  # Advances one call of play, or `budget` cycles if that is shorter,
75
- # then yields whatever the SID recorded over it. Returns the cycles
76
- # advanced.
78
+ # then yields whatever the SIDs recorded over it, in stereo. Returns
79
+ # the cycles advanced.
77
80
  def frame(budget, &)
78
81
  call(@tune.play_address)
79
82
  cycles = [budget, period].min
80
- cycles.times { step }
81
- @sid.drain_samples.each(&)
83
+ if @multi
84
+ cycles.times { step }
85
+ else
86
+ cycles.times do
87
+ @sid.cycle!
88
+ @cpu.cycle!
89
+ end
90
+ end
91
+ @stereo.mix(&)
82
92
  cycles
83
93
  end
84
94
 
@@ -86,10 +96,10 @@ module Badline
86
96
 
87
97
  private
88
98
 
89
- # A video frame, or for a CIA-timed song one period of CIA 1 timer A,
99
+ # A video frame, or for a CIA-timed subtune one period of CIA 1 timer A,
90
100
  # which init and play are both free to reprogram.
91
101
  def period
92
- return (@tune.ntsc? ? NTSC_FRAME_CYCLES : FRAME_CYCLES) unless @tune.cia_timed?(@song + 1)
102
+ return (@tune.ntsc? ? NTSC_FRAME_CYCLES : FRAME_CYCLES) unless @tune.cia_timed?(@subtune + 1)
93
103
 
94
104
  @bus.cia1.timer_a_latch + 1
95
105
  end
@@ -117,6 +127,7 @@ module Badline
117
127
 
118
128
  def step
119
129
  @sid.cycle!
130
+ @extras.each(&:cycle!) if @multi
120
131
  @cpu.cycle!
121
132
  end
122
133
 
@@ -2,73 +2,115 @@
2
2
 
3
3
  module Badline
4
4
  module Audio
5
- # Runs `badline-ruby --headless` or `--audio-out` once its options have
6
- # parsed: picks the song and its length, then plays it or renders it
7
- # 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 tunes as #run does, reports an error on stderr
13
+ # under the program's name, and returns the exit status.
14
+ def self.run(options, sink:, console:)
15
+ new(options, sink:, console:).run
16
+ 0
17
+ rescue Error => e
18
+ warn "#{options.program}: #{e.message}"
19
+ 1
20
+ rescue Interrupt
21
+ 130
22
+ end
23
+
12
24
  # Playing needs `sink`, which builds the audio device given the rate
13
25
  # to ask for and whether it has to be exact, and, on a terminal,
14
26
  # `console`, which builds the terminal to play on given the input and
15
- # output. The front end hands them in, so the native badline can hand
16
- # in its own: badline-ruby's are SDLSink and Console.
27
+ # output. Each build's entry point hands them in: both builds' sink is
28
+ # Frontend::AudioSink, and the console is badline-ruby's Console or
29
+ # the native badline's Native::Console.
17
30
  def initialize(options, sink: nil, console: nil, out: $stdout, input: $stdin)
18
31
  @options = options
19
32
  @out = out
20
33
  @input = input
21
34
  @sink = sink
22
35
  @console = console
23
- @lengths = {}
36
+ @announced = nil
37
+ @terminal = nil
38
+ @stil_lists = {}
24
39
  end
25
40
 
26
41
  def run
27
42
  @options.render? ? render : play
28
- rescue Renderer::UnknownFormatError, Storage::SIDFile::FormatError => e
43
+ rescue Renderer::UnknownFormatError => e
29
44
  raise Error, e.message
30
45
  end
31
46
 
32
- def tune
33
- @tune ||= Storage::SIDFile.new(@options.tune_path)
34
- rescue Storage::SIDFile::FormatError => e
35
- raise Error, "#{@options.tune_path}: #{e.message}"
36
- 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?
37
55
 
38
- def song
39
- @song ||= (@options.song || tune.start_song).tap do |song|
40
- raise Error, "no song #{song}: the tune has #{tune.songs}" if song > tune.songs
56
+ queue
41
57
  end
42
58
  end
43
59
 
44
- def seconds = length(song)
60
+ def tune = checked(queue.entry).tune
45
61
 
46
- def length(song)
47
- @lengths[song] ||= @options.seconds || songlength(song) || @options.fallback_seconds
48
- end
62
+ def subtune = checked(queue.entry).part
63
+
64
+ def seconds = length(subtune)
49
65
 
50
- def sid_model = @options.sid_model || tune.sid_model
66
+ def length(subtune) = queue.entry.length(subtune)
51
67
 
52
- def interactive? = @options.tui? && @input.tty? && @out.tty?
68
+ def silence(subtune) = queue.entry.silence(subtune)
69
+
70
+ def sid_models = queue.entry.sid_models
71
+
72
+ def interactive? = @options.player_window? || (@options.tui? && @input.tty? && @out.tty?)
53
73
 
54
74
  private
55
75
 
56
- def renderer(song, rate)
57
- Renderer.new(tune, seconds: length(song), song:, rate:, sid_model:).tap do |renderer|
58
- 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)
59
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
60
99
  end
61
100
 
62
101
  def render
63
- renderer = renderer(song, @options.rate)
64
- @out.puts describe
65
- @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..."
66
107
  started = now
67
- renderer.render(@options.audio_out) { |done| progress(done) }
68
- report(now - started)
108
+ renderer.render(@options.audio_out) { |done| progress(done, seconds) }
109
+ report(renderer.rendered, now - started)
69
110
  end
70
111
 
71
112
  def play
113
+ checked(queue.entry) if queue.size == 1
72
114
  sink = open_sink
73
115
  interactive? ? play_interactively(sink) : play_plainly(sink)
74
116
  ensure
@@ -76,23 +118,85 @@ module Badline
76
118
  end
77
119
 
78
120
  def play_plainly(sink)
79
- @out.puts describe
80
- @out.puts "Playing #{seconds}s on the #{model_name} at #{sink.rate} Hz. Ctrl-C stops."
81
- playback = Playback.new(sink, on_underrun: -> { @out.puts "\rRunning below real time, so it will stutter." })
82
- result = playback.play(renderer(song, sink.rate)) { |played| progress(played) }
83
- 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
84
139
  @out.print "\n" unless @options.quiet?
85
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 }
86
163
  end
87
164
 
88
165
  def play_interactively(sink)
89
- console = @console.call(input: @input, output: @out)
90
- jukebox = Jukebox.new(sink, console, songs: tune.songs, renderer: method(:renderer), length: method(:length))
91
- console.session do
92
- console.header([tune.name, tune.author, tune.released].reject(&:empty?) +
93
- ["#{model_name} at #{sink.rate} Hz"])
94
- jukebox.run(song)
95
- 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)
96
200
  end
97
201
 
98
202
  def open_sink
@@ -101,36 +205,40 @@ module Badline
101
205
  raise Error, "can't open the audio device: #{e.message}"
102
206
  end
103
207
 
104
- def describe
105
- header = [tune.name, tune.author, tune.released].reject(&:empty?).join(" / ")
106
- 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})"
107
211
  end
108
212
 
109
- # HVSC's database is keyed by the tune's MD5 and lists one length per
110
- # song.
111
- def songlength(song) = songlengths&.at(song - 1)
112
-
113
- def songlengths
114
- 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
115
218
 
116
- @songlengths_read = true
117
- path = @options.songlengths || Storage::SongLengths.locate(@options.tune_path)
118
- @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
119
223
  end
120
224
 
121
- 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
122
230
 
123
- def progress(done)
231
+ def progress(done, total)
124
232
  return if @options.quiet?
125
233
 
126
- @out.print format("\r%<done>6.1fs / %<total>.1fs", done:, total: seconds)
234
+ @out.print format("\r%<done>6.1fs / %<total>.1fs", done:, total:)
127
235
  @out.flush
128
236
  end
129
237
 
130
- def report(elapsed)
238
+ def report(rendered, elapsed)
131
239
  @out.print "\r" unless @options.quiet?
132
240
  @out.puts format("Wrote %<path>s in %<elapsed>.1fs (%<speed>.2fx real time).",
133
- path: @options.audio_out, elapsed:, speed: seconds / elapsed)
241
+ path: @options.audio_out, elapsed:, speed: rendered / elapsed)
134
242
  end
135
243
 
136
244
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -6,7 +6,8 @@ require "io/wait"
6
6
  module Badline
7
7
  module Audio
8
8
  # The terminal of `badline-ruby --headless`, put in raw mode with
9
- # io/console.
9
+ # io/console. It is badline-ruby's side of the console seam, and
10
+ # exe/badline-ruby requires it: the native badline's is Native::Console.
10
11
  class Console < Terminal
11
12
  private
12
13