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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +77 -0
- data/CLAUDE.md +10 -9
- data/README.md +135 -42
- data/doc/pinned-behaviour.md +131 -30
- data/exe/badline-ruby +28 -24
- 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 +15 -1
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +167 -59
- data/lib/badline/audio/console.rb +2 -1
- 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 -3
- data/lib/badline/computer.rb +6 -3
- data/lib/badline/cpu/boundary.rb +54 -0
- data/lib/badline/cpu/core.rb +165 -0
- data/lib/badline/cpu.rb +3 -214
- data/lib/badline/drive1541/cpu.rb +29 -0
- data/lib/badline/drive1541.rb +1 -0
- data/lib/badline/ffi.rb +157 -0
- data/lib/badline/frontend/app.rb +225 -0
- data/lib/badline/frontend/audio_sink.rb +73 -0
- data/lib/badline/frontend/boot.rb +52 -0
- data/lib/badline/frontend/buttons.rb +90 -0
- data/lib/badline/frontend/controls.rb +163 -0
- data/lib/badline/frontend/drive_led.rb +51 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/frame_rate.rb +83 -0
- data/lib/badline/frontend/gamepads.rb +76 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/keys.rb +48 -0
- data/lib/badline/frontend/pacer.rb +100 -0
- data/lib/badline/frontend/pad_port.rb +61 -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/screen.rb +65 -0
- data/lib/badline/frontend/screenshot.rb +22 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -0
- data/lib/badline/frontend/snapshots.rb +60 -0
- data/lib/badline/frontend/sound.rb +153 -0
- data/lib/badline/frontend/timeline.rb +171 -0
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +32 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +13 -8
- data/lib/badline/options/event.rb +94 -0
- data/lib/badline/options/help.rb +107 -0
- data/lib/badline/options/option.rb +29 -0
- data/lib/badline/options/table.rb +64 -0
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +204 -149
- data/lib/badline/reu/dma/saved_state.rb +50 -0
- data/lib/badline/reu/dma.rb +56 -47
- data/lib/badline/reu/saved_state.rb +2 -1
- data/lib/badline/reu.rb +19 -10
- data/lib/badline/sdl.rb +133 -51
- 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/noise_writeback.rb +33 -11
- data/lib/badline/sid/waveform.rb +22 -8
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/snapshot/state.rb +1 -1
- 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
- data/lib/badline/vic/bank.rb +21 -0
- data/lib/badline/vic.rb +15 -6
- metadata +49 -19
- data/.claude/hooks/session-start.sh +0 -57
- data/.claude/settings.json +0 -14
- data/lib/badline/audio/sdl_sink.rb +0 -70
- data/lib/badline/audio/stream.rb +0 -123
- data/lib/badline/gui/application.rb +0 -243
- data/lib/badline/gui/drive_led_pane.rb +0 -29
- data/lib/badline/gui/gamepads.rb +0 -104
- data/lib/badline/gui/joy_map.rb +0 -27
- data/lib/badline/gui/key_map.rb +0 -39
- data/lib/badline/gui/palette.rb +0 -35
- data/lib/badline/gui/pane.rb +0 -36
- data/lib/badline/gui/screen_pane.rb +0 -46
- data/lib/badline/gui/snapshots.rb +0 -67
- data/lib/badline/gui/window.rb +0 -61
- data/lib/badline/gui.rb +0 -14
- data/lib/badline/sdl/events.rb +0 -49
- data/lib/badline/sdl/functions.rb +0 -74
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
44
|
-
rescue Badline::
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
data/lib/badline/address_bus.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
data/lib/badline/audio/aiff.rb
CHANGED
|
@@ -13,14 +13,14 @@ module Badline
|
|
|
13
13
|
|
|
14
14
|
def header
|
|
15
15
|
["FORM", 0, "AIFF",
|
|
16
|
-
"COMM", 18,
|
|
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, [
|
|
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,
|
|
49
|
+
def initialize(tune, subtune: nil, sid_models: tune.sid_models)
|
|
50
50
|
@tune = tune
|
|
51
|
-
@
|
|
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
|
-
@
|
|
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, @
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
|
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?(@
|
|
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
|
|
data/lib/badline/audio/cli.rb
CHANGED
|
@@ -2,73 +2,115 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Runs
|
|
6
|
-
# parsed:
|
|
7
|
-
# to a file. Played on a terminal,
|
|
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 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.
|
|
16
|
-
#
|
|
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
|
-
@
|
|
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
|
|
43
|
+
rescue Renderer::UnknownFormatError => e
|
|
29
44
|
raise Error, e.message
|
|
30
45
|
end
|
|
31
46
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
|
60
|
+
def tune = checked(queue.entry).tune
|
|
45
61
|
|
|
46
|
-
def
|
|
47
|
-
|
|
48
|
-
|
|
62
|
+
def subtune = checked(queue.entry).part
|
|
63
|
+
|
|
64
|
+
def seconds = length(subtune)
|
|
49
65
|
|
|
50
|
-
def
|
|
66
|
+
def length(subtune) = queue.entry.length(subtune)
|
|
51
67
|
|
|
52
|
-
def
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
@
|
|
65
|
-
@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..."
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
90
|
-
jukebox = Jukebox.new(sink,
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 =
|
|
106
|
-
header.empty? ? "
|
|
208
|
+
def describe(entry, subtune)
|
|
209
|
+
header = entry.header.join(" / ")
|
|
210
|
+
header.empty? ? "subtune #{subtune}" : "#{header} (subtune #{subtune})"
|
|
107
211
|
end
|
|
108
212
|
|
|
109
|
-
#
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|