badline 0.2.1 → 0.4.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/.claude/hooks/session-start.sh +57 -0
- data/.claude/settings.json +14 -0
- data/CHANGELOG.md +107 -0
- data/CLAUDE.md +51 -16
- data/CONTRIBUTING.md +95 -3
- data/README.md +119 -51
- data/SECURITY.md +9 -0
- data/doc/pinned-behaviour.md +293 -39
- data/exe/badline-ruby +59 -0
- data/lib/badline/address_bus.rb +28 -14
- data/lib/badline/addressable.rb +1 -1
- data/lib/badline/audio/aiff.rb +8 -3
- data/lib/badline/audio/bare_player.rb +20 -3
- data/lib/badline/audio/cli.rb +17 -12
- data/lib/badline/audio/console.rb +12 -58
- data/lib/badline/audio/machine_player.rb +9 -6
- data/lib/badline/audio/playback.rb +3 -0
- data/lib/badline/audio/renderer.rb +3 -3
- data/lib/badline/audio/sdl_sink.rb +20 -52
- data/lib/badline/audio/stream.rb +117 -0
- data/lib/badline/audio/terminal.rb +90 -0
- data/lib/badline/audio.rb +3 -2
- data/lib/badline/cartridge/easy_flash.rb +6 -2
- data/lib/badline/cartridge/epyx_fastload.rb +1 -1
- data/lib/badline/cartridge/geo_ram.rb +63 -0
- data/lib/badline/cartridge/mach5.rb +1 -1
- data/lib/badline/cartridge/magic_desk.rb +5 -1
- data/lib/badline/cartridge/ocean.rb +5 -2
- data/lib/badline/cartridge/rex_utility.rb +1 -1
- data/lib/badline/cartridge/rgcd.rb +1 -1
- data/lib/badline/cartridge/simons_basic.rb +2 -2
- data/lib/badline/cartridge/westermann.rb +2 -2
- data/lib/badline/cartridge.rb +9 -0
- data/lib/badline/checkpoint.rb +98 -0
- data/lib/badline/cia/interrupt_register.rb +34 -10
- data/lib/badline/cia.rb +19 -10
- data/lib/badline/color_memory.rb +2 -2
- data/lib/badline/computer.rb +34 -14
- data/lib/badline/cpu.rb +2 -2
- data/lib/badline/gui/application.rb +70 -26
- data/lib/badline/gui/gamepads.rb +22 -22
- data/lib/badline/gui/joy_map.rb +1 -1
- data/lib/badline/gui/key_map.rb +6 -1
- data/lib/badline/gui/pane.rb +7 -6
- data/lib/badline/gui/screen_pane.rb +9 -13
- data/lib/badline/gui/window.rb +35 -20
- data/lib/badline/gui.rb +1 -1
- data/lib/badline/input/mouse1351.rb +21 -6
- data/lib/badline/input/paddles.rb +11 -6
- data/lib/badline/kernal_trap/channel.rb +102 -7
- data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
- data/lib/badline/kernal_trap/drive/channels.rb +53 -0
- data/lib/badline/kernal_trap/drive/memory.rb +109 -0
- data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
- data/lib/badline/kernal_trap/drive/status.rb +3 -0
- data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
- data/lib/badline/kernal_trap/drive/writes.rb +92 -0
- data/lib/badline/kernal_trap/drive.rb +109 -131
- data/lib/badline/kernal_trap/file.rb +6 -14
- data/lib/badline/kernal_trap/load.rb +6 -0
- data/lib/badline/kernal_trap/save.rb +13 -7
- data/lib/badline/keyboard.rb +19 -3
- data/lib/badline/media.rb +44 -13
- data/lib/badline/memory.rb +2 -2
- data/lib/badline/options.rb +209 -0
- data/lib/badline/ram_expansion/banking.rb +30 -0
- data/lib/badline/ram_expansion/plus256k.rb +38 -0
- data/lib/badline/ram_expansion/plus60k.rb +31 -0
- data/lib/badline/ram_expansion/unexpanded.rb +22 -0
- data/lib/badline/ram_expansion.rb +21 -0
- data/lib/badline/region.rb +51 -0
- data/lib/badline/rom.rb +14 -5
- data/lib/badline/sdl/events.rb +47 -0
- data/lib/badline/sdl/functions.rb +73 -0
- data/lib/badline/sdl.rb +67 -0
- data/lib/badline/sid/catch_up.rb +110 -0
- data/lib/badline/sid/decimator.rb +6 -1
- data/lib/badline/sid/filter.rb +5 -5
- data/lib/badline/sid/waveform/fast_forward.rb +10 -10
- data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
- data/lib/badline/sid/waveform.rb +8 -8
- data/lib/badline/sid.rb +19 -93
- data/lib/badline/storage/d64_image.rb +10 -0
- data/lib/badline/storage/d71_image.rb +12 -0
- data/lib/badline/storage/d81_image.rb +9 -0
- data/lib/badline/storage/disk_image/bam.rb +111 -0
- data/lib/badline/storage/disk_image/directory.rb +79 -0
- data/lib/badline/storage/disk_image/writing.rb +110 -0
- data/lib/badline/storage/disk_image.rb +49 -4
- data/lib/badline/storage/host_directory.rb +17 -4
- data/lib/badline/storage/sid_file.rb +36 -2
- data/lib/badline/storage.rb +18 -1
- data/lib/badline/time_of_day.rb +11 -2
- data/lib/badline/version.rb +1 -1
- data/lib/badline/via/control_lines.rb +104 -0
- data/lib/badline/via/interrupt_register.rb +58 -0
- data/lib/badline/via/shift_register.rb +135 -0
- data/lib/badline/via/timer1.rb +82 -0
- data/lib/badline/via/timer2.rb +84 -0
- data/lib/badline/via.rb +262 -0
- data/lib/badline/vic/bank.rb +4 -3
- data/lib/badline/vic/color_patches.rb +6 -3
- data/lib/badline/vic/display_state.rb +4 -0
- data/lib/badline/vic/graphics_shifter.rb +49 -8
- data/lib/badline/vic/registers.rb +19 -27
- data/lib/badline/vic/sequencer.rb +18 -18
- data/lib/badline/vic/sequencer_output.rb +4 -4
- data/lib/badline/vic/sprite/internal_bus.rb +4 -0
- data/lib/badline/vic/sprite/shifter.rb +25 -3
- data/lib/badline/vic/sprite.rb +5 -4
- data/lib/badline/vic/sprites.rb +8 -3
- data/lib/badline/vic.rb +98 -46
- data/lib/badline.rb +5 -0
- data/packaging/homebrew/badline.rb +34 -0
- metadata +37 -20
- data/exe/badline +0 -75
- data/exe/badline-sid +0 -34
- data/lib/badline/audio/options.rb +0 -137
|
@@ -8,6 +8,10 @@ module Badline
|
|
|
8
8
|
# which is what makes this path about three times faster than the whole
|
|
9
9
|
# machine.
|
|
10
10
|
#
|
|
11
|
+
# Nothing on the rig ties it to PAL, so an NTSC tune gets an NTSC
|
|
12
|
+
# machine: 60 Hz frames, the NTSC KERNAL's timer and `$02a6`, and a
|
|
13
|
+
# clock of 1022727 Hz for the SID's output.
|
|
14
|
+
#
|
|
11
15
|
# Calls go through a six-byte stub whose JSR operand the dispatch trap
|
|
12
16
|
# rewrites:
|
|
13
17
|
#
|
|
@@ -23,6 +27,11 @@ module Badline
|
|
|
23
27
|
# 312 raster lines of 63 cycles.
|
|
24
28
|
FRAME_CYCLES = 19_656
|
|
25
29
|
|
|
30
|
+
# 263 raster lines of 65 cycles.
|
|
31
|
+
NTSC_FRAME_CYCLES = 17_095
|
|
32
|
+
|
|
33
|
+
NTSC_CLOCK_HZ = 1_022_727
|
|
34
|
+
|
|
26
35
|
# A tune's init routine is free to unpack itself, but not forever.
|
|
27
36
|
INIT_LIMIT = 10_000_000
|
|
28
37
|
|
|
@@ -30,6 +39,11 @@ module Badline
|
|
|
30
39
|
# second.
|
|
31
40
|
KERNAL_TIMER = 0x4025
|
|
32
41
|
|
|
42
|
+
NTSC_KERNAL_TIMER = 0x4295
|
|
43
|
+
|
|
44
|
+
# The KERNAL's PAL/NTSC flag, 1 for PAL.
|
|
45
|
+
VIDEO_STANDARD = 0x02a6
|
|
46
|
+
|
|
33
47
|
attr_reader :sid
|
|
34
48
|
|
|
35
49
|
def initialize(tune, song: nil, sid_model: tune.sid_model)
|
|
@@ -39,7 +53,8 @@ module Badline
|
|
|
39
53
|
# The CPU port as the KERNAL leaves it, which a PSID tune expects.
|
|
40
54
|
@bus.poke(0x00, 0x2f)
|
|
41
55
|
@bus.poke(0x01, 0x37)
|
|
42
|
-
@bus.
|
|
56
|
+
@bus.poke(VIDEO_STANDARD, tune.ntsc? ? 0 : 1)
|
|
57
|
+
@bus.cia1.timer_a_latch = tune.ntsc? ? NTSC_KERNAL_TIMER : KERNAL_TIMER
|
|
43
58
|
@cpu = CPU.new(@bus)
|
|
44
59
|
@sid = @bus.sid
|
|
45
60
|
@idle = false
|
|
@@ -67,12 +82,14 @@ module Badline
|
|
|
67
82
|
cycles
|
|
68
83
|
end
|
|
69
84
|
|
|
85
|
+
def clock_hz = @tune.ntsc? ? NTSC_CLOCK_HZ : TimeOfDay::CLOCK_HZ
|
|
86
|
+
|
|
70
87
|
private
|
|
71
88
|
|
|
72
|
-
# A
|
|
89
|
+
# A video frame, or for a CIA-timed song one period of CIA 1 timer A,
|
|
73
90
|
# which init and play are both free to reprogram.
|
|
74
91
|
def period
|
|
75
|
-
return FRAME_CYCLES unless @tune.cia_timed?(@song + 1)
|
|
92
|
+
return (@tune.ntsc? ? NTSC_FRAME_CYCLES : FRAME_CYCLES) unless @tune.cia_timed?(@song + 1)
|
|
76
93
|
|
|
77
94
|
@bus.cia1.timer_a_latch + 1
|
|
78
95
|
end
|
data/lib/badline/audio/cli.rb
CHANGED
|
@@ -2,19 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Audio
|
|
5
|
-
# Runs `badline-
|
|
6
|
-
# its length, then plays it or renders it
|
|
7
|
-
# terminal, the console lets the listener step
|
|
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.
|
|
8
9
|
class CLI
|
|
9
10
|
class Error < StandardError; end
|
|
10
11
|
|
|
11
|
-
# `sink
|
|
12
|
-
# for and whether it has to be exact
|
|
13
|
-
|
|
12
|
+
# Playing needs `sink`, which builds the audio device given the rate
|
|
13
|
+
# to ask for and whether it has to be exact, and, on a terminal,
|
|
14
|
+
# `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.
|
|
17
|
+
def initialize(options, sink: nil, console: nil, out: $stdout, input: $stdin)
|
|
14
18
|
@options = options
|
|
15
19
|
@out = out
|
|
16
20
|
@input = input
|
|
17
21
|
@sink = sink
|
|
22
|
+
@console = console
|
|
18
23
|
@lengths = {}
|
|
19
24
|
end
|
|
20
25
|
|
|
@@ -39,7 +44,7 @@ module Badline
|
|
|
39
44
|
def seconds = length(song)
|
|
40
45
|
|
|
41
46
|
def length(song)
|
|
42
|
-
@lengths[song] ||= @options.seconds || songlength(song) ||
|
|
47
|
+
@lengths[song] ||= @options.seconds || songlength(song) || @options.fallback_seconds
|
|
43
48
|
end
|
|
44
49
|
|
|
45
50
|
def sid_model = @options.sid_model || tune.sid_model
|
|
@@ -57,9 +62,9 @@ module Badline
|
|
|
57
62
|
def render
|
|
58
63
|
renderer = renderer(song, @options.rate)
|
|
59
64
|
@out.puts describe
|
|
60
|
-
@out.puts "Rendering #{seconds}s for the #{model_name} to #{@options.
|
|
65
|
+
@out.puts "Rendering #{seconds}s for the #{model_name} to #{@options.audio_out} at #{@options.rate} Hz..."
|
|
61
66
|
started = now
|
|
62
|
-
renderer.render(@options.
|
|
67
|
+
renderer.render(@options.audio_out) { |done| progress(done) }
|
|
63
68
|
report(now - started)
|
|
64
69
|
end
|
|
65
70
|
|
|
@@ -81,7 +86,7 @@ module Badline
|
|
|
81
86
|
end
|
|
82
87
|
|
|
83
88
|
def play_interactively(sink)
|
|
84
|
-
console =
|
|
89
|
+
console = @console.call(input: @input, output: @out)
|
|
85
90
|
jukebox = Jukebox.new(sink, console, songs: tune.songs, renderer: method(:renderer), length: method(:length))
|
|
86
91
|
console.session do
|
|
87
92
|
console.header([tune.name, tune.author, tune.released].reject(&:empty?) +
|
|
@@ -92,7 +97,7 @@ module Badline
|
|
|
92
97
|
|
|
93
98
|
def open_sink
|
|
94
99
|
@sink.call(rate: @options.rate, exact_rate: @options.rate_given?)
|
|
95
|
-
rescue
|
|
100
|
+
rescue Playback::DeviceError => e
|
|
96
101
|
raise Error, "can't open the audio device: #{e.message}"
|
|
97
102
|
end
|
|
98
103
|
|
|
@@ -124,7 +129,7 @@ module Badline
|
|
|
124
129
|
def report(elapsed)
|
|
125
130
|
@out.print "\r" unless @options.quiet?
|
|
126
131
|
@out.puts format("Wrote %<path>s in %<elapsed>.1fs (%<speed>.2fx real time).",
|
|
127
|
-
path: @options.
|
|
132
|
+
path: @options.audio_out, elapsed:, speed: seconds / elapsed)
|
|
128
133
|
end
|
|
129
134
|
|
|
130
135
|
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
@@ -5,70 +5,24 @@ require "io/wait"
|
|
|
5
5
|
|
|
6
6
|
module Badline
|
|
7
7
|
module Audio
|
|
8
|
-
# The terminal
|
|
9
|
-
#
|
|
10
|
-
|
|
11
|
-
class Console
|
|
12
|
-
KEYS = {
|
|
13
|
-
"n" => :next, "\e[C" => :next,
|
|
14
|
-
"p" => :previous, "\e[D" => :previous,
|
|
15
|
-
" " => :pause,
|
|
16
|
-
"q" => :quit
|
|
17
|
-
}.freeze
|
|
18
|
-
|
|
19
|
-
def initialize(input:, output:)
|
|
20
|
-
@input = input
|
|
21
|
-
@output = output
|
|
22
|
-
@closed = false
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Raw input and a hidden cursor for the length of the block.
|
|
26
|
-
def session(&)
|
|
27
|
-
@output.print "\e[?25l"
|
|
28
|
-
@input.tty? ? @input.raw(intr: true, &) : yield
|
|
29
|
-
ensure
|
|
30
|
-
@output.print "\e[?25h\r\n"
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Waits up to `seconds` for keys and returns what they ask for.
|
|
34
|
-
def wait(seconds)
|
|
35
|
-
return idle(seconds) if @closed
|
|
36
|
-
return [] unless @input.wait_readable(seconds)
|
|
37
|
-
|
|
38
|
-
case (keys = @input.read_nonblock(64, exception: false))
|
|
39
|
-
when :wait_readable then []
|
|
40
|
-
when nil then close_input(seconds)
|
|
41
|
-
else keys.scan(/\e\[[A-D]|./m).filter_map { |key| KEYS[key] }
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def header(lines)
|
|
46
|
-
lines.each { |line| @output.print "#{line}\r\n" }
|
|
47
|
-
@output.print "n/→ next p/← previous space pause q quit\r\n"
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def status(song:, songs:, elapsed:, length:, notes: [])
|
|
51
|
-
line = format("song %<song>d/%<songs>d %<elapsed>s / %<length>s",
|
|
52
|
-
song:, songs:, elapsed: clock(elapsed), length: clock(length))
|
|
53
|
-
@output.print "\r\e[K#{([line] + notes).join(' ')}"
|
|
54
|
-
@output.flush
|
|
55
|
-
end
|
|
56
|
-
|
|
8
|
+
# The terminal of `badline-ruby --headless`, put in raw mode with
|
|
9
|
+
# io/console.
|
|
10
|
+
class Console < Terminal
|
|
57
11
|
private
|
|
58
12
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
13
|
+
# Ctrl-C still raises Interrupt.
|
|
14
|
+
def raw!
|
|
15
|
+
mode = @input.console_mode
|
|
16
|
+
@input.raw!(intr: true)
|
|
17
|
+
mode
|
|
62
18
|
end
|
|
63
19
|
|
|
64
|
-
def
|
|
65
|
-
|
|
66
|
-
[]
|
|
20
|
+
def restore(mode)
|
|
21
|
+
@input.console_mode = mode
|
|
67
22
|
end
|
|
68
23
|
|
|
69
|
-
def
|
|
70
|
-
|
|
71
|
-
format("%<minutes>d:%<seconds>02d", minutes: whole / 60, seconds: whole % 60)
|
|
24
|
+
def readable?(seconds)
|
|
25
|
+
!@input.wait_readable(seconds).nil?
|
|
72
26
|
end
|
|
73
27
|
end
|
|
74
28
|
end
|
|
@@ -4,8 +4,8 @@ module Badline
|
|
|
4
4
|
module Audio
|
|
5
5
|
# Runs a tune on the whole machine, for the RSID tunes that install their
|
|
6
6
|
# own interrupts and expect a booted C64 underneath. The tune goes in
|
|
7
|
-
#
|
|
8
|
-
# when the CPU
|
|
7
|
+
# the way `Media` puts it in: BASIC SYSes the driver stub, or RUNs a
|
|
8
|
+
# BASIC tune. Rendering starts when the CPU gets there.
|
|
9
9
|
class MachinePlayer
|
|
10
10
|
FRAME_CYCLES = BarePlayer::FRAME_CYCLES
|
|
11
11
|
|
|
@@ -22,12 +22,15 @@ module Badline
|
|
|
22
22
|
|
|
23
23
|
def start
|
|
24
24
|
@computer.on_init { inject }
|
|
25
|
-
@computer.cpu.install_trap(@tune.
|
|
25
|
+
@computer.cpu.install_trap(@tune.entry_address) { begin_playing }
|
|
26
26
|
@computer.cycle! until @started || @computer.cycles > START_LIMIT
|
|
27
27
|
end
|
|
28
28
|
|
|
29
29
|
def sid = @computer.sid
|
|
30
30
|
|
|
31
|
+
# The machine is PAL whatever the tune asks for.
|
|
32
|
+
def clock_hz = TimeOfDay::CLOCK_HZ
|
|
33
|
+
|
|
31
34
|
# Advances one PAL frame, or `budget` cycles if that is shorter, then
|
|
32
35
|
# yields whatever the SID recorded over it. Returns the cycles advanced.
|
|
33
36
|
def frame(budget, &)
|
|
@@ -41,13 +44,13 @@ module Badline
|
|
|
41
44
|
|
|
42
45
|
def inject
|
|
43
46
|
@computer.ram.write(@tune.load_address, @tune.data)
|
|
44
|
-
@
|
|
45
|
-
@computer.type_text(
|
|
47
|
+
@tune.boot_memory(song: @song).each { |address, bytes| @computer.ram.write(address, bytes) }
|
|
48
|
+
@computer.type_text(@tune.boot_command)
|
|
46
49
|
@injected = true
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
def begin_playing
|
|
50
|
-
return
|
|
53
|
+
return if @started || !@injected
|
|
51
54
|
|
|
52
55
|
@computer.sid.synthesize!
|
|
53
56
|
@started = true
|
|
@@ -12,6 +12,9 @@ module Badline
|
|
|
12
12
|
# real time lets the queue run dry and the audio stutters; `on_underrun`
|
|
13
13
|
# hears about the first time.
|
|
14
14
|
class Playback
|
|
15
|
+
# What a sink raises when the audio device won't open.
|
|
16
|
+
class DeviceError < StandardError; end
|
|
17
|
+
|
|
15
18
|
AHEAD = 0.15
|
|
16
19
|
|
|
17
20
|
def initialize(sink, ahead: AHEAD, sleeper: ->(seconds) { sleep(seconds) }, on_underrun: nil)
|
|
@@ -60,7 +60,7 @@ module Badline
|
|
|
60
60
|
# The cycles it takes to close exactly `total_samples` windows; the SID
|
|
61
61
|
# records floor(cycles * rate / clock) of them.
|
|
62
62
|
def total_cycles
|
|
63
|
-
((total_samples *
|
|
63
|
+
((total_samples * player.clock_hz) + @rate - 1) / @rate
|
|
64
64
|
end
|
|
65
65
|
|
|
66
66
|
def container_for(path)
|
|
@@ -71,13 +71,13 @@ module Badline
|
|
|
71
71
|
end
|
|
72
72
|
|
|
73
73
|
def each_frame
|
|
74
|
-
player.sid.record(rate: @rate, filter_chunk: @filter_chunk)
|
|
74
|
+
player.sid.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
|
|
75
75
|
total = total_cycles
|
|
76
76
|
remaining = total
|
|
77
77
|
while remaining.positive?
|
|
78
78
|
samples = []
|
|
79
79
|
remaining -= player.frame(remaining) { |sample| samples << sample }
|
|
80
|
-
yield samples, (total - remaining).fdiv(
|
|
80
|
+
yield samples, (total - remaining).fdiv(player.clock_hz)
|
|
81
81
|
end
|
|
82
82
|
end
|
|
83
83
|
end
|
|
@@ -4,98 +4,66 @@ module Badline
|
|
|
4
4
|
module Audio
|
|
5
5
|
# The host's audio device, fed through SDL's queue: mono signed 16-bit
|
|
6
6
|
# samples go in at the device's rate and SDL plays them out behind us.
|
|
7
|
-
#
|
|
8
|
-
# ruby-sdl2 only wraps SDL_mixer, which can't stream raw samples, so the
|
|
9
|
-
# queue API is called through Fiddle on the libSDL2 the extension has
|
|
10
|
-
# already loaded.
|
|
11
7
|
class SDLSink
|
|
12
|
-
class Error <
|
|
13
|
-
|
|
14
|
-
INIT_AUDIO = 0x10
|
|
15
|
-
|
|
16
|
-
AUDIO_S16SYS = [1].pack("s").getbyte(0) == 1 ? 0x8010 : 0x9010
|
|
17
|
-
|
|
18
|
-
ALLOW_FREQUENCY_CHANGE = 0x01
|
|
8
|
+
class Error < Playback::DeviceError; end
|
|
19
9
|
|
|
20
10
|
# int freq; Uint16 format; Uint8 channels, silence; Uint16 samples,
|
|
21
11
|
# padding; Uint32 size; then the callback and userdata pointers.
|
|
22
12
|
SPEC_LAYOUT = "lSCCSSLQQ"
|
|
23
13
|
|
|
24
|
-
FUNCTIONS = {
|
|
25
|
-
SDL_InitSubSystem: [%i[uint32], :int],
|
|
26
|
-
SDL_QuitSubSystem: [%i[uint32], :void],
|
|
27
|
-
SDL_GetError: [[], :string],
|
|
28
|
-
SDL_OpenAudioDevice: [%i[pointer int pointer pointer int], :uint32],
|
|
29
|
-
SDL_CloseAudioDevice: [%i[uint32], :void],
|
|
30
|
-
SDL_PauseAudioDevice: [%i[uint32 int], :void],
|
|
31
|
-
SDL_QueueAudio: [%i[uint32 pointer uint32], :int],
|
|
32
|
-
SDL_GetQueuedAudioSize: [%i[uint32], :uint32],
|
|
33
|
-
SDL_ClearQueuedAudio: [%i[uint32], :void]
|
|
34
|
-
}.freeze
|
|
35
|
-
|
|
36
14
|
attr_reader :rate
|
|
37
15
|
|
|
38
16
|
# With `exact_rate` false SDL may pick the device's own rate instead,
|
|
39
17
|
# which #rate then reports.
|
|
40
18
|
def initialize(rate:, exact_rate: false, buffer: 1024)
|
|
41
19
|
load_sdl
|
|
42
|
-
check(
|
|
43
|
-
@device = open_device(rate, exact_rate ? 0 :
|
|
20
|
+
check(SDL::InitSubSystem.call(SDL::INIT_AUDIO))
|
|
21
|
+
@device = open_device(rate, exact_rate ? 0 : SDL::AUDIO_ALLOW_FREQUENCY_CHANGE, buffer)
|
|
44
22
|
end
|
|
45
23
|
|
|
46
24
|
def queue(samples)
|
|
47
25
|
data = samples.pack("s*")
|
|
48
|
-
check(
|
|
26
|
+
check(SDL::QueueAudio.call(@device, data, data.bytesize))
|
|
49
27
|
end
|
|
50
28
|
|
|
51
|
-
def queued_seconds =
|
|
29
|
+
def queued_seconds = SDL::GetQueuedAudioSize.call(@device).fdiv(2 * rate)
|
|
52
30
|
|
|
53
|
-
def start =
|
|
31
|
+
def start = SDL::PauseAudioDevice.call(@device, 0)
|
|
54
32
|
|
|
55
|
-
def pause =
|
|
33
|
+
def pause = SDL::PauseAudioDevice.call(@device, 1)
|
|
56
34
|
|
|
57
|
-
def clear =
|
|
35
|
+
def clear = SDL::ClearQueuedAudio.call(@device)
|
|
58
36
|
|
|
59
37
|
def close
|
|
60
38
|
return unless @device
|
|
61
39
|
|
|
62
|
-
|
|
63
|
-
|
|
40
|
+
SDL::CloseAudioDevice.call(@device)
|
|
41
|
+
SDL::QuitSubSystem.call(SDL::INIT_AUDIO)
|
|
64
42
|
@device = nil
|
|
65
43
|
end
|
|
66
44
|
|
|
67
45
|
private
|
|
68
46
|
|
|
69
47
|
def open_device(rate, allowed_changes, buffer)
|
|
70
|
-
wanted = [rate, AUDIO_S16SYS, 1, 0, buffer, 0, 0, 0, 0].pack(SPEC_LAYOUT)
|
|
48
|
+
wanted = [rate, SDL::AUDIO_S16SYS, 1, 0, buffer, 0, 0, 0, 0].pack(SPEC_LAYOUT)
|
|
71
49
|
obtained = "\0".b * 32
|
|
72
|
-
device =
|
|
73
|
-
raise Error,
|
|
50
|
+
device = SDL::OpenAudioDevice.call(nil, 0, wanted, obtained, allowed_changes)
|
|
51
|
+
raise Error, SDL.error if device.zero?
|
|
74
52
|
|
|
75
53
|
@rate = obtained.unpack1("l")
|
|
76
54
|
device
|
|
77
55
|
end
|
|
78
56
|
|
|
79
|
-
def check(result)
|
|
80
|
-
raise Error, sdl(:SDL_GetError) if result.negative?
|
|
81
|
-
|
|
82
|
-
result
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def sdl(name, *) = @functions.fetch(name).call(*)
|
|
86
|
-
|
|
87
57
|
def load_sdl
|
|
88
|
-
require "
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
[name, Fiddle::Function.new(Fiddle::Handle::DEFAULT[name.to_s],
|
|
92
|
-
arguments.map { |type| fiddle_type(type) }, fiddle_type(result))]
|
|
93
|
-
end
|
|
58
|
+
require "badline/sdl"
|
|
59
|
+
rescue SDL::Error => e
|
|
60
|
+
raise Error, e.message
|
|
94
61
|
end
|
|
95
62
|
|
|
96
|
-
def
|
|
97
|
-
|
|
98
|
-
|
|
63
|
+
def check(result)
|
|
64
|
+
raise Error, SDL.error if result.negative?
|
|
65
|
+
|
|
66
|
+
result
|
|
99
67
|
end
|
|
100
68
|
end
|
|
101
69
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# Plays a running machine's SID through a sink, for the window: #feed
|
|
6
|
+
# queues whatever the SID recorded since the last call, once a frame,
|
|
7
|
+
# and #pace holds the machine to the device's clock.
|
|
8
|
+
#
|
|
9
|
+
# The device starts once the queue holds `ahead` seconds. Below real time
|
|
10
|
+
# the queue runs dry, and the device then stops until it holds `ahead`
|
|
11
|
+
# again, so the sound stutters with silent gaps rather than slowing
|
|
12
|
+
# down; `on_underrun` hears about the first time. A frame that would
|
|
13
|
+
# take the queue past `limit` seconds, as an unpaced machine above real
|
|
14
|
+
# time makes, is dropped whole.
|
|
15
|
+
class Stream
|
|
16
|
+
AHEAD = 0.08
|
|
17
|
+
LIMIT = 0.25
|
|
18
|
+
|
|
19
|
+
attr_reader :underruns, :dropped
|
|
20
|
+
|
|
21
|
+
# What #pace reads the time from and sleeps on: #now and #sleep.
|
|
22
|
+
attr_writer :clock
|
|
23
|
+
|
|
24
|
+
def initialize(sink, sid, ahead: AHEAD, limit: LIMIT, on_underrun: nil)
|
|
25
|
+
@sink = sink
|
|
26
|
+
@sid = sid
|
|
27
|
+
@ahead = ahead
|
|
28
|
+
@limit = limit
|
|
29
|
+
@on_underrun = on_underrun
|
|
30
|
+
@clock = Clock.new
|
|
31
|
+
@next_frame = nil
|
|
32
|
+
@started = false
|
|
33
|
+
@muted = false
|
|
34
|
+
@underruns = 0
|
|
35
|
+
@dropped = 0
|
|
36
|
+
sid.record(rate: sink.rate)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def rate = @sink.rate
|
|
40
|
+
|
|
41
|
+
def muted? = @muted
|
|
42
|
+
|
|
43
|
+
def playing? = @started
|
|
44
|
+
|
|
45
|
+
# Muting drops the queue; unmuting fills it to `ahead` again before
|
|
46
|
+
# the device restarts.
|
|
47
|
+
def toggle_mute
|
|
48
|
+
@muted = !@muted
|
|
49
|
+
return unless @muted
|
|
50
|
+
|
|
51
|
+
halt
|
|
52
|
+
@sink.clear
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def feed
|
|
56
|
+
samples = @sid.drain_samples
|
|
57
|
+
return if @muted || samples.empty?
|
|
58
|
+
|
|
59
|
+
queued = @sink.queued_seconds
|
|
60
|
+
return @dropped += samples.size if queued + samples.size.fdiv(rate) > @limit
|
|
61
|
+
|
|
62
|
+
underrun! if @started && queued.zero?
|
|
63
|
+
@sink.queue(samples)
|
|
64
|
+
start if @sink.queued_seconds >= @ahead
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Waits out the frame just run. While the device plays, that is until
|
|
68
|
+
# it has played the queue down to `ahead`, so the machine runs at the
|
|
69
|
+
# device's rate and the queue neither drifts dry nor grows. Muted or
|
|
70
|
+
# filling up, the clock paces frames `seconds` apart instead.
|
|
71
|
+
def pace(seconds)
|
|
72
|
+
return wait_for_device if playing?
|
|
73
|
+
|
|
74
|
+
now = @clock.now
|
|
75
|
+
@next_frame = now if @next_frame.nil? || @next_frame < now
|
|
76
|
+
@clock.sleep(@next_frame - now)
|
|
77
|
+
@next_frame += seconds
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def close = @sink.close
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def start
|
|
85
|
+
return if @started
|
|
86
|
+
|
|
87
|
+
@started = true
|
|
88
|
+
@sink.start
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def wait_for_device
|
|
92
|
+
@next_frame = nil
|
|
93
|
+
while (excess = @sink.queued_seconds - @ahead).positive?
|
|
94
|
+
@clock.sleep([excess, 0.001].max)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def halt
|
|
99
|
+
@started = false
|
|
100
|
+
@sink.pause
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def underrun!
|
|
104
|
+
@underruns += 1
|
|
105
|
+
@on_underrun&.call if @underruns == 1
|
|
106
|
+
halt
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The host's monotonic clock.
|
|
110
|
+
class Clock
|
|
111
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
112
|
+
|
|
113
|
+
def sleep(seconds) = Kernel.sleep(seconds)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Audio
|
|
5
|
+
# The terminal face of `--headless`: the tune's header, a status line
|
|
6
|
+
# redrawn in place, and single keypresses read without waiting for
|
|
7
|
+
# Return. Ctrl-C still interrupts. A subclass puts the terminal in raw
|
|
8
|
+
# mode with #raw! and #restore and waits for keys with #readable?:
|
|
9
|
+
# Console with io/console for badline-ruby, and Native::Console with
|
|
10
|
+
# stty and poll(2) for the native badline.
|
|
11
|
+
class Terminal
|
|
12
|
+
KEYS = {
|
|
13
|
+
"n" => :next, "\e[C" => :next,
|
|
14
|
+
"p" => :previous, "\e[D" => :previous,
|
|
15
|
+
" " => :pause,
|
|
16
|
+
"q" => :quit
|
|
17
|
+
}.freeze
|
|
18
|
+
|
|
19
|
+
def initialize(input:, output:)
|
|
20
|
+
@input = input
|
|
21
|
+
@output = output
|
|
22
|
+
@closed = false
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raw input and a hidden cursor for the length of the block.
|
|
26
|
+
def session
|
|
27
|
+
@output.print "\e[?25l"
|
|
28
|
+
mode = raw! if @input.tty?
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
restore(mode) if mode
|
|
32
|
+
@output.print "\e[?25h\r\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Waits up to `seconds` for keys and returns what they ask for.
|
|
36
|
+
def wait(seconds)
|
|
37
|
+
return idle(seconds) if @closed
|
|
38
|
+
return [] unless readable?(seconds)
|
|
39
|
+
|
|
40
|
+
case (keys = @input.read_nonblock(64, exception: false))
|
|
41
|
+
when :wait_readable then []
|
|
42
|
+
when nil then close_input(seconds)
|
|
43
|
+
else keys.scan(/\e\[[A-D]|./m).filter_map { |key| KEYS[key] }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def header(lines)
|
|
48
|
+
lines.each { |line| @output.print "#{line}\r\n" }
|
|
49
|
+
@output.print "n/→ next p/← previous space pause q quit\r\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def status(song:, songs:, elapsed:, length:, notes: [])
|
|
53
|
+
line = format("song %<song>d/%<songs>d %<elapsed>s / %<length>s",
|
|
54
|
+
song:, songs:, elapsed: clock(elapsed), length: clock(length))
|
|
55
|
+
@output.print "\r\e[K#{([line] + notes).join(' ')}"
|
|
56
|
+
@output.flush
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
# A subclass puts the terminal in raw mode and returns the mode to
|
|
62
|
+
# restore afterwards.
|
|
63
|
+
def raw! = nil
|
|
64
|
+
|
|
65
|
+
def restore(mode) = mode
|
|
66
|
+
|
|
67
|
+
# A subclass waits up to `seconds` for input and says whether any
|
|
68
|
+
# came.
|
|
69
|
+
def readable?(seconds)
|
|
70
|
+
sleep(seconds)
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def close_input(seconds)
|
|
75
|
+
@closed = true
|
|
76
|
+
idle(seconds)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def idle(seconds)
|
|
80
|
+
sleep(seconds)
|
|
81
|
+
[]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def clock(seconds)
|
|
85
|
+
whole = seconds.floor
|
|
86
|
+
format("%<minutes>d:%<seconds>02d", minutes: whole / 60, seconds: whole % 60)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
data/lib/badline/audio.rb
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "badline/audio/options"
|
|
4
3
|
require "badline/audio/pcm_writer"
|
|
5
4
|
require "badline/audio/wav"
|
|
6
5
|
require "badline/audio/aiff"
|
|
7
6
|
require "badline/audio/bare_player"
|
|
8
7
|
require "badline/audio/machine_player"
|
|
9
8
|
require "badline/audio/renderer"
|
|
10
|
-
require "badline/audio/sdl_sink"
|
|
11
9
|
require "badline/audio/playback"
|
|
10
|
+
require "badline/audio/sdl_sink"
|
|
11
|
+
require "badline/audio/stream"
|
|
12
|
+
require "badline/audio/terminal"
|
|
12
13
|
require "badline/audio/console"
|
|
13
14
|
require "badline/audio/jukebox"
|
|
14
15
|
require "badline/audio/cli"
|
|
@@ -18,7 +18,10 @@ module Badline
|
|
|
18
18
|
class EasyFlash < Cartridge
|
|
19
19
|
BANKS = 64
|
|
20
20
|
EAPI_OFFSET = 0x1800
|
|
21
|
-
|
|
21
|
+
|
|
22
|
+
def self.eapi
|
|
23
|
+
@eapi ||= ROM.read("eapi/eapi-am29f040-14").drop(2).freeze
|
|
24
|
+
end
|
|
22
25
|
|
|
23
26
|
attr_reader :low_flash, :high_flash
|
|
24
27
|
|
|
@@ -110,7 +113,8 @@ module Badline
|
|
|
110
113
|
def install_eapi(high)
|
|
111
114
|
return unless high[EAPI_OFFSET, 4] == "eapi".bytes
|
|
112
115
|
|
|
113
|
-
|
|
116
|
+
eapi = EasyFlash.eapi
|
|
117
|
+
high[EAPI_OFFSET, eapi.length] = eapi
|
|
114
118
|
end
|
|
115
119
|
|
|
116
120
|
def place(flash_data, offset, bytes)
|