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
|
@@ -2,53 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Plays a
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Plays a Media::Queue of tunes interactively: the console's keys step
|
|
6
|
+
# between tunes and between a tune's subtunes, seek within the subtune, pause,
|
|
7
|
+
# turn the queue's shuffle, loop and all-subtunes modes on and off, and
|
|
8
|
+
# quit, and the status line follows along. Seeking runs the subtune
|
|
9
|
+
# silently up to where it was asked to go: on from where it is to go
|
|
10
|
+
# forward, and from the start again to go back. A subtune that plays to
|
|
11
|
+
# its end moves on as Media::Queue#advance says. It stops at the end of
|
|
12
|
+
# the queue, on q, or on Ctrl-C.
|
|
9
13
|
#
|
|
10
|
-
# `renderer` builds the renderer for
|
|
11
|
-
#
|
|
14
|
+
# `renderer` builds the renderer for an entry's subtune at the sink's
|
|
15
|
+
# rate, or returns nil for an entry that can't play, which is skipped
|
|
16
|
+
# in the direction the queue was stepping. `length` gives an entry's
|
|
17
|
+
# subtune's length in seconds.
|
|
12
18
|
class Jukebox
|
|
13
|
-
|
|
19
|
+
SEEK_STEP = 10.0
|
|
20
|
+
|
|
21
|
+
# How long a wait on an empty queue lasts before it checks again.
|
|
22
|
+
IDLE = 0.05
|
|
23
|
+
|
|
24
|
+
# How long the status line says the player fell below real time after
|
|
25
|
+
# the last time it did.
|
|
26
|
+
BELOW_NOTE = 3.0
|
|
27
|
+
|
|
28
|
+
def initialize(sink, console, queue:, renderer:, length:)
|
|
14
29
|
@sink = sink
|
|
15
30
|
@console = console
|
|
16
|
-
@
|
|
31
|
+
@queue = queue
|
|
17
32
|
@renderer = renderer
|
|
18
33
|
@length = length
|
|
19
|
-
@
|
|
34
|
+
@backward = false
|
|
35
|
+
@below_at = -BELOW_NOTE
|
|
36
|
+
@from = 0.0
|
|
20
37
|
end
|
|
21
38
|
|
|
22
|
-
# Returns the result of the last
|
|
23
|
-
|
|
39
|
+
# Returns the result of the last subtune's Playback#play, or :unplayable
|
|
40
|
+
# when the last entry reached couldn't play.
|
|
41
|
+
def run
|
|
42
|
+
skipped = 0
|
|
24
43
|
loop do
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
44
|
+
return :stopped unless tunes_to_play?
|
|
45
|
+
|
|
46
|
+
@moved = false
|
|
47
|
+
@quit = false
|
|
48
|
+
result = play(@queue.entry, @queue.part)
|
|
49
|
+
return result if @quit
|
|
50
|
+
next if @moved
|
|
51
|
+
|
|
52
|
+
skipped = result == :unplayable ? skipped + 1 : 0
|
|
53
|
+
return result if skipped >= @queue.size || !move_on(result)
|
|
29
54
|
end
|
|
30
55
|
end
|
|
31
56
|
|
|
32
57
|
private
|
|
33
58
|
|
|
34
|
-
|
|
35
|
-
|
|
59
|
+
# Waits on the console while the queue is empty, as a window's is
|
|
60
|
+
# until files are dropped on it. Returns false when asked to quit.
|
|
61
|
+
def tunes_to_play?
|
|
62
|
+
while @queue.empty?
|
|
63
|
+
actions = @console.wait(IDLE)
|
|
64
|
+
return false if actions.include?(:quit)
|
|
65
|
+
|
|
66
|
+
@queue.add(@console.dropped) if actions.include?(:drop)
|
|
67
|
+
end
|
|
68
|
+
true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def move_on(result)
|
|
72
|
+
return @queue.advance if result == :finished
|
|
73
|
+
return false unless result == :unplayable
|
|
74
|
+
return @queue.advance unless @backward
|
|
75
|
+
|
|
76
|
+
@queue.previous_entry || @queue.advance
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def play(entry, subtune)
|
|
80
|
+
@entry = entry
|
|
81
|
+
@subtune = subtune
|
|
36
82
|
@elapsed = 0.0
|
|
83
|
+
renderer = @renderer.call(entry, subtune, @sink.rate)
|
|
84
|
+
return :unplayable if renderer.nil?
|
|
85
|
+
|
|
86
|
+
renderer.from = @from
|
|
87
|
+
@streaming = renderer
|
|
88
|
+
@from = 0.0
|
|
89
|
+
@backward = false
|
|
37
90
|
@playback = Playback.new(@sink, sleeper: ->(seconds) { react(@console.wait(seconds)) },
|
|
38
|
-
on_underrun: -> { @
|
|
91
|
+
on_underrun: -> { @below_at = now })
|
|
39
92
|
draw
|
|
40
|
-
@playback.play(
|
|
93
|
+
@playback.play(renderer) do |played|
|
|
41
94
|
@elapsed = played
|
|
42
95
|
react(@console.wait(0))
|
|
43
|
-
draw
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def following(song, result)
|
|
48
|
-
case @action
|
|
49
|
-
when :next then song + 1
|
|
50
|
-
when :previous then song - 1
|
|
51
|
-
else song + 1 if result == :finished && song < @songs
|
|
96
|
+
draw unless @moved
|
|
52
97
|
end
|
|
53
98
|
end
|
|
54
99
|
|
|
@@ -56,29 +101,69 @@ module Badline
|
|
|
56
101
|
actions.each do |action|
|
|
57
102
|
case action
|
|
58
103
|
when :pause then toggle_pause
|
|
59
|
-
when :
|
|
60
|
-
when :
|
|
61
|
-
when :
|
|
104
|
+
when :shuffle then @queue.toggle_shuffle
|
|
105
|
+
when :loop then @queue.toggle_loop
|
|
106
|
+
when :all_subtunes then @queue.toggle_all_parts
|
|
107
|
+
when :quit then quit
|
|
108
|
+
when :drop then @queue.add(@console.dropped)
|
|
109
|
+
when :seek then seek(@console.seek_to)
|
|
110
|
+
when :forward then seek(@elapsed + SEEK_STEP)
|
|
111
|
+
when :back then seek(@elapsed - SEEK_STEP)
|
|
112
|
+
else skip if step(action)
|
|
62
113
|
end
|
|
63
114
|
end
|
|
115
|
+
draw unless actions.empty? || @moved
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def step(action)
|
|
119
|
+
@backward = action == :previous if %i[next previous].include?(action)
|
|
120
|
+
case action
|
|
121
|
+
when :next then @queue.next_entry
|
|
122
|
+
when :previous then @queue.previous_entry
|
|
123
|
+
when :next_subtune then @queue.next_part
|
|
124
|
+
when :previous_subtune then @queue.previous_part
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def seek(seconds)
|
|
129
|
+
target = seconds.clamp(0.0, [@length.call(@entry, @subtune) - 1.0, 0.0].max)
|
|
130
|
+
return @streaming.from = target if target > @elapsed
|
|
131
|
+
|
|
132
|
+
@from = target
|
|
133
|
+
skip
|
|
64
134
|
end
|
|
65
135
|
|
|
66
|
-
def skip
|
|
67
|
-
@
|
|
136
|
+
def skip
|
|
137
|
+
@moved = true
|
|
138
|
+
@playback.stop
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def quit
|
|
142
|
+
@quit = true
|
|
68
143
|
@playback.stop
|
|
69
144
|
end
|
|
70
145
|
|
|
71
146
|
def toggle_pause
|
|
72
147
|
@playback.paused? ? @playback.resume : @playback.pause
|
|
73
|
-
draw
|
|
74
148
|
end
|
|
75
149
|
|
|
76
150
|
def draw
|
|
151
|
+
@console.place(@queue.position, @queue.size)
|
|
152
|
+
@console.status(subtune: @subtune, subtunes: @entry.parts, elapsed: @elapsed,
|
|
153
|
+
length: @length.call(@entry, @subtune), notes:)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def notes
|
|
77
157
|
notes = []
|
|
78
158
|
notes << "paused" if @playback.paused?
|
|
79
|
-
notes << "
|
|
80
|
-
|
|
159
|
+
notes << "shuffle" if @queue.shuffle?
|
|
160
|
+
notes << "loop" if @queue.loop?
|
|
161
|
+
notes << "all subtunes" if @queue.all_parts?
|
|
162
|
+
notes << "below real time" if now - @below_at < BELOW_NOTE
|
|
163
|
+
notes
|
|
81
164
|
end
|
|
165
|
+
|
|
166
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
82
167
|
end
|
|
83
168
|
end
|
|
84
169
|
end
|
|
@@ -14,10 +14,13 @@ module Badline
|
|
|
14
14
|
# Boot, plus room for the keyboard buffer to type the SYS.
|
|
15
15
|
START_LIMIT = 15_000_000
|
|
16
16
|
|
|
17
|
-
def initialize(tune,
|
|
17
|
+
def initialize(tune, subtune: nil, sid_models: tune.sid_models)
|
|
18
18
|
@tune = tune
|
|
19
|
-
@
|
|
20
|
-
@computer = Computer.new(sid_model:)
|
|
19
|
+
@subtune = subtune || tune.start_subtune
|
|
20
|
+
@computer = Computer.new(sid_model: sid_models[0])
|
|
21
|
+
@stereo = Stereo.new(@computer.address_bus, tune, sid_models)
|
|
22
|
+
@extras = @stereo.extras
|
|
23
|
+
@multi = !@extras.empty?
|
|
21
24
|
region = @computer.region
|
|
22
25
|
@frame_cycles = region.cycles_per_line * region.lines_per_frame
|
|
23
26
|
@injected = false
|
|
@@ -27,28 +30,40 @@ module Badline
|
|
|
27
30
|
def start
|
|
28
31
|
@computer.on_init { inject }
|
|
29
32
|
@computer.cpu.install_trap(@tune.entry_address) { begin_playing }
|
|
30
|
-
|
|
33
|
+
step until @started || @computer.cycles > START_LIMIT
|
|
31
34
|
end
|
|
32
35
|
|
|
36
|
+
attr_reader :stereo
|
|
37
|
+
|
|
33
38
|
def sid = @computer.sid
|
|
34
39
|
|
|
35
40
|
def clock_hz = @computer.region.clock_hz
|
|
36
41
|
|
|
37
42
|
# Advances one frame of the machine, or `budget` cycles if that is
|
|
38
|
-
# shorter, then yields whatever the
|
|
39
|
-
# cycles advanced.
|
|
43
|
+
# shorter, then yields whatever the SIDs recorded over it, in stereo.
|
|
44
|
+
# Returns the cycles advanced.
|
|
40
45
|
def frame(budget, &)
|
|
41
46
|
cycles = [budget, @frame_cycles].min
|
|
42
|
-
|
|
43
|
-
|
|
47
|
+
if @multi
|
|
48
|
+
cycles.times { step }
|
|
49
|
+
else
|
|
50
|
+
cycles.times { @computer.cycle! }
|
|
51
|
+
end
|
|
52
|
+
@stereo.mix(&)
|
|
44
53
|
cycles
|
|
45
54
|
end
|
|
46
55
|
|
|
47
56
|
private
|
|
48
57
|
|
|
58
|
+
# The extra SIDs clock ahead of the CPU, as the machine's own does.
|
|
59
|
+
def step
|
|
60
|
+
@extras.each(&:cycle!) if @multi
|
|
61
|
+
@computer.cycle!
|
|
62
|
+
end
|
|
63
|
+
|
|
49
64
|
def inject
|
|
50
65
|
@computer.ram.write(@tune.load_address, @tune.data)
|
|
51
|
-
@tune.boot_memory(
|
|
66
|
+
@tune.boot_memory(subtune: @subtune).each { |address, bytes| @computer.ram.write(address, bytes) }
|
|
52
67
|
@computer.type_text(@tune.boot_command)
|
|
53
68
|
@injected = true
|
|
54
69
|
end
|
|
@@ -56,7 +71,7 @@ module Badline
|
|
|
56
71
|
def begin_playing
|
|
57
72
|
return if @started || !@injected
|
|
58
73
|
|
|
59
|
-
@
|
|
74
|
+
@stereo.synthesize!
|
|
60
75
|
@started = true
|
|
61
76
|
end
|
|
62
77
|
end
|
|
@@ -2,24 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Streams
|
|
6
|
-
# patching the header's length fields once the stream ends.
|
|
5
|
+
# Streams signed 16-bit samples into a container, buffering them and
|
|
6
|
+
# patching the header's length fields once the stream ends. With more
|
|
7
|
+
# than one channel the samples come interleaved, a frame at a time.
|
|
7
8
|
class PCMWriter
|
|
8
9
|
BUFFER = 4096
|
|
9
10
|
|
|
10
|
-
attr_reader :rate, :samples
|
|
11
|
+
attr_reader :rate, :channels, :samples
|
|
11
12
|
|
|
12
|
-
def self.open(path, rate:)
|
|
13
|
+
def self.open(path, rate:, channels: 1)
|
|
13
14
|
File.open(path, "wb") do |io|
|
|
14
|
-
writer = new(io, rate:)
|
|
15
|
+
writer = new(io, rate:, channels:)
|
|
15
16
|
yield writer
|
|
16
17
|
writer.finish
|
|
17
18
|
end
|
|
18
19
|
end
|
|
19
20
|
|
|
20
|
-
def initialize(io, rate:)
|
|
21
|
+
def initialize(io, rate:, channels: 1)
|
|
21
22
|
@io = io
|
|
22
23
|
@rate = rate
|
|
24
|
+
@channels = channels
|
|
23
25
|
@samples = 0
|
|
24
26
|
@buffer = []
|
|
25
27
|
@io.write(header)
|
|
@@ -48,6 +50,8 @@ module Badline
|
|
|
48
50
|
|
|
49
51
|
def data_size = @samples * 2
|
|
50
52
|
|
|
53
|
+
def frames = @samples / @channels
|
|
54
|
+
|
|
51
55
|
def write_at(offset, bytes)
|
|
52
56
|
@io.seek(offset)
|
|
53
57
|
@io.write(bytes)
|
|
@@ -7,10 +7,11 @@ module Badline
|
|
|
7
7
|
# queue has drained below `ahead` seconds, and the device only starts
|
|
8
8
|
# once the queue first reaches it.
|
|
9
9
|
#
|
|
10
|
-
# A sink answers #rate, #queue(samples),
|
|
11
|
-
# and #clear; whoever
|
|
10
|
+
# A sink answers #rate, #queue(samples), which takes stereo samples
|
|
11
|
+
# interleaved, #queued_seconds, #start, #pause and #clear; whoever
|
|
12
|
+
# opened it closes it. An emulator running below
|
|
12
13
|
# real time lets the queue run dry and the audio stutters; `on_underrun`
|
|
13
|
-
# hears
|
|
14
|
+
# hears of each time it does.
|
|
14
15
|
class Playback
|
|
15
16
|
# What a sink raises when the audio device won't open.
|
|
16
17
|
class DeviceError < StandardError; end
|
|
@@ -73,6 +74,8 @@ module Badline
|
|
|
73
74
|
private
|
|
74
75
|
|
|
75
76
|
def enqueue(samples)
|
|
77
|
+
return if samples.empty?
|
|
78
|
+
|
|
76
79
|
wait_below(@ahead)
|
|
77
80
|
underrun! if @started && @sink.queued_seconds.zero?
|
|
78
81
|
@sink.queue(samples)
|
|
@@ -92,8 +95,6 @@ module Badline
|
|
|
92
95
|
end
|
|
93
96
|
|
|
94
97
|
def underrun!
|
|
95
|
-
return if @underrun
|
|
96
|
-
|
|
97
98
|
@underrun = true
|
|
98
99
|
@on_underrun&.call
|
|
99
100
|
end
|
|
@@ -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
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
-
@
|
|
45
|
+
@subtune = subtune
|
|
26
46
|
@rate = rate
|
|
27
|
-
@
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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
|