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.
Files changed (119) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/hooks/session-start.sh +57 -0
  3. data/.claude/settings.json +14 -0
  4. data/CHANGELOG.md +107 -0
  5. data/CLAUDE.md +51 -16
  6. data/CONTRIBUTING.md +95 -3
  7. data/README.md +119 -51
  8. data/SECURITY.md +9 -0
  9. data/doc/pinned-behaviour.md +293 -39
  10. data/exe/badline-ruby +59 -0
  11. data/lib/badline/address_bus.rb +28 -14
  12. data/lib/badline/addressable.rb +1 -1
  13. data/lib/badline/audio/aiff.rb +8 -3
  14. data/lib/badline/audio/bare_player.rb +20 -3
  15. data/lib/badline/audio/cli.rb +17 -12
  16. data/lib/badline/audio/console.rb +12 -58
  17. data/lib/badline/audio/machine_player.rb +9 -6
  18. data/lib/badline/audio/playback.rb +3 -0
  19. data/lib/badline/audio/renderer.rb +3 -3
  20. data/lib/badline/audio/sdl_sink.rb +20 -52
  21. data/lib/badline/audio/stream.rb +117 -0
  22. data/lib/badline/audio/terminal.rb +90 -0
  23. data/lib/badline/audio.rb +3 -2
  24. data/lib/badline/cartridge/easy_flash.rb +6 -2
  25. data/lib/badline/cartridge/epyx_fastload.rb +1 -1
  26. data/lib/badline/cartridge/geo_ram.rb +63 -0
  27. data/lib/badline/cartridge/mach5.rb +1 -1
  28. data/lib/badline/cartridge/magic_desk.rb +5 -1
  29. data/lib/badline/cartridge/ocean.rb +5 -2
  30. data/lib/badline/cartridge/rex_utility.rb +1 -1
  31. data/lib/badline/cartridge/rgcd.rb +1 -1
  32. data/lib/badline/cartridge/simons_basic.rb +2 -2
  33. data/lib/badline/cartridge/westermann.rb +2 -2
  34. data/lib/badline/cartridge.rb +9 -0
  35. data/lib/badline/checkpoint.rb +98 -0
  36. data/lib/badline/cia/interrupt_register.rb +34 -10
  37. data/lib/badline/cia.rb +19 -10
  38. data/lib/badline/color_memory.rb +2 -2
  39. data/lib/badline/computer.rb +34 -14
  40. data/lib/badline/cpu.rb +2 -2
  41. data/lib/badline/gui/application.rb +70 -26
  42. data/lib/badline/gui/gamepads.rb +22 -22
  43. data/lib/badline/gui/joy_map.rb +1 -1
  44. data/lib/badline/gui/key_map.rb +6 -1
  45. data/lib/badline/gui/pane.rb +7 -6
  46. data/lib/badline/gui/screen_pane.rb +9 -13
  47. data/lib/badline/gui/window.rb +35 -20
  48. data/lib/badline/gui.rb +1 -1
  49. data/lib/badline/input/mouse1351.rb +21 -6
  50. data/lib/badline/input/paddles.rb +11 -6
  51. data/lib/badline/kernal_trap/channel.rb +102 -7
  52. data/lib/badline/kernal_trap/drive/block_commands.rb +108 -0
  53. data/lib/badline/kernal_trap/drive/channels.rb +53 -0
  54. data/lib/badline/kernal_trap/drive/memory.rb +109 -0
  55. data/lib/badline/kernal_trap/drive/parameters.rb +28 -0
  56. data/lib/badline/kernal_trap/drive/status.rb +3 -0
  57. data/lib/badline/kernal_trap/drive/write_file.rb +41 -0
  58. data/lib/badline/kernal_trap/drive/writes.rb +92 -0
  59. data/lib/badline/kernal_trap/drive.rb +109 -131
  60. data/lib/badline/kernal_trap/file.rb +6 -14
  61. data/lib/badline/kernal_trap/load.rb +6 -0
  62. data/lib/badline/kernal_trap/save.rb +13 -7
  63. data/lib/badline/keyboard.rb +19 -3
  64. data/lib/badline/media.rb +44 -13
  65. data/lib/badline/memory.rb +2 -2
  66. data/lib/badline/options.rb +209 -0
  67. data/lib/badline/ram_expansion/banking.rb +30 -0
  68. data/lib/badline/ram_expansion/plus256k.rb +38 -0
  69. data/lib/badline/ram_expansion/plus60k.rb +31 -0
  70. data/lib/badline/ram_expansion/unexpanded.rb +22 -0
  71. data/lib/badline/ram_expansion.rb +21 -0
  72. data/lib/badline/region.rb +51 -0
  73. data/lib/badline/rom.rb +14 -5
  74. data/lib/badline/sdl/events.rb +47 -0
  75. data/lib/badline/sdl/functions.rb +73 -0
  76. data/lib/badline/sdl.rb +67 -0
  77. data/lib/badline/sid/catch_up.rb +110 -0
  78. data/lib/badline/sid/decimator.rb +6 -1
  79. data/lib/badline/sid/filter.rb +5 -5
  80. data/lib/badline/sid/waveform/fast_forward.rb +10 -10
  81. data/lib/badline/sid/waveform/noise_writeback.rb +32 -14
  82. data/lib/badline/sid/waveform.rb +8 -8
  83. data/lib/badline/sid.rb +19 -93
  84. data/lib/badline/storage/d64_image.rb +10 -0
  85. data/lib/badline/storage/d71_image.rb +12 -0
  86. data/lib/badline/storage/d81_image.rb +9 -0
  87. data/lib/badline/storage/disk_image/bam.rb +111 -0
  88. data/lib/badline/storage/disk_image/directory.rb +79 -0
  89. data/lib/badline/storage/disk_image/writing.rb +110 -0
  90. data/lib/badline/storage/disk_image.rb +49 -4
  91. data/lib/badline/storage/host_directory.rb +17 -4
  92. data/lib/badline/storage/sid_file.rb +36 -2
  93. data/lib/badline/storage.rb +18 -1
  94. data/lib/badline/time_of_day.rb +11 -2
  95. data/lib/badline/version.rb +1 -1
  96. data/lib/badline/via/control_lines.rb +104 -0
  97. data/lib/badline/via/interrupt_register.rb +58 -0
  98. data/lib/badline/via/shift_register.rb +135 -0
  99. data/lib/badline/via/timer1.rb +82 -0
  100. data/lib/badline/via/timer2.rb +84 -0
  101. data/lib/badline/via.rb +262 -0
  102. data/lib/badline/vic/bank.rb +4 -3
  103. data/lib/badline/vic/color_patches.rb +6 -3
  104. data/lib/badline/vic/display_state.rb +4 -0
  105. data/lib/badline/vic/graphics_shifter.rb +49 -8
  106. data/lib/badline/vic/registers.rb +19 -27
  107. data/lib/badline/vic/sequencer.rb +18 -18
  108. data/lib/badline/vic/sequencer_output.rb +4 -4
  109. data/lib/badline/vic/sprite/internal_bus.rb +4 -0
  110. data/lib/badline/vic/sprite/shifter.rb +25 -3
  111. data/lib/badline/vic/sprite.rb +5 -4
  112. data/lib/badline/vic/sprites.rb +8 -3
  113. data/lib/badline/vic.rb +98 -46
  114. data/lib/badline.rb +5 -0
  115. data/packaging/homebrew/badline.rb +34 -0
  116. metadata +37 -20
  117. data/exe/badline +0 -75
  118. data/exe/badline-sid +0 -34
  119. 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.cia1.timer_a_latch = KERNAL_TIMER
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 PAL frame, or for a CIA-timed song one period of CIA 1 timer 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
@@ -2,19 +2,24 @@
2
2
 
3
3
  module Badline
4
4
  module Audio
5
- # Runs `badline-sid` once its options have parsed: picks the song and
6
- # its length, then plays it or renders it to a file. Played on a
7
- # terminal, the console lets the listener step between songs.
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` builds the audio device for playback, given the rate to ask
12
- # for and whether it has to be exact.
13
- def initialize(options, out: $stdout, input: $stdin, sink: SDLSink.method(:new))
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) || Options::FALLBACK_SECONDS
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.output} at #{@options.rate} Hz..."
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.output) { |done| progress(done) }
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 = Console.new(input: @input, output: @out)
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 SDLSink::Error => e
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.output, elapsed:, speed: seconds / elapsed)
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 face of `badline-sid`: the tune's header, a status line
9
- # redrawn in place, and single keypresses read without waiting for
10
- # Return. Ctrl-C still interrupts.
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
- def close_input(seconds)
60
- @closed = true
61
- idle(seconds)
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 idle(seconds)
65
- sleep(seconds)
66
- []
20
+ def restore(mode)
21
+ @input.console_mode = mode
67
22
  end
68
23
 
69
- def clock(seconds)
70
- whole = seconds.floor
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
- # through the same stub `Media` uses and BASIC SYSes it; rendering starts
8
- # when the CPU reaches that stub.
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.driver_address) { begin_playing }
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
- @computer.ram.write(@tune.driver_address, @tune.driver(song: @song))
45
- @computer.type_text("sys#{@tune.driver_address}\r")
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 unless @injected
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 * TimeOfDay::CLOCK_HZ) + @rate - 1) / @rate
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(TimeOfDay::CLOCK_HZ)
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 < StandardError; end
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(sdl(:SDL_InitSubSystem, INIT_AUDIO))
43
- @device = open_device(rate, exact_rate ? 0 : ALLOW_FREQUENCY_CHANGE, buffer)
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(sdl(:SDL_QueueAudio, @device, data, data.bytesize))
26
+ check(SDL::QueueAudio.call(@device, data, data.bytesize))
49
27
  end
50
28
 
51
- def queued_seconds = sdl(:SDL_GetQueuedAudioSize, @device).fdiv(2 * rate)
29
+ def queued_seconds = SDL::GetQueuedAudioSize.call(@device).fdiv(2 * rate)
52
30
 
53
- def start = sdl(:SDL_PauseAudioDevice, @device, 0)
31
+ def start = SDL::PauseAudioDevice.call(@device, 0)
54
32
 
55
- def pause = sdl(:SDL_PauseAudioDevice, @device, 1)
33
+ def pause = SDL::PauseAudioDevice.call(@device, 1)
56
34
 
57
- def clear = sdl(:SDL_ClearQueuedAudio, @device)
35
+ def clear = SDL::ClearQueuedAudio.call(@device)
58
36
 
59
37
  def close
60
38
  return unless @device
61
39
 
62
- sdl(:SDL_CloseAudioDevice, @device)
63
- sdl(:SDL_QuitSubSystem, INIT_AUDIO)
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 = sdl(:SDL_OpenAudioDevice, nil, 0, wanted, obtained, allowed_changes)
73
- raise Error, sdl(:SDL_GetError) if device.zero?
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 "fiddle"
89
- require "sdl2"
90
- @functions = FUNCTIONS.to_h do |name, (arguments, result)|
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 fiddle_type(type)
97
- { uint32: Fiddle::TYPE_UINT32_T, int: Fiddle::TYPE_INT, void: Fiddle::TYPE_VOID,
98
- pointer: Fiddle::TYPE_VOIDP, string: Fiddle::TYPE_CONST_STRING }.fetch(type)
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
- EAPI = File.binread(File.expand_path("../roms/eapi/eapi-am29f040-14", __dir__)).bytes.drop(2).freeze
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
- high[EAPI_OFFSET, EAPI.length] = EAPI
116
+ eapi = EasyFlash.eapi
117
+ high[EAPI_OFFSET, eapi.length] = eapi
114
118
  end
115
119
 
116
120
  def place(flash_data, offset, bytes)
@@ -61,7 +61,7 @@ module Badline
61
61
  end
62
62
 
63
63
  def install_chips(chips)
64
- @rom = banks_from(chips).first.compact.first
64
+ @rom = banks_from(chips).first.compact.first || EMPTY_BANK
65
65
  @roml = Window.new(self, @rom)
66
66
  @discharged_at = 0
67
67
  self.mode = :rom8k