badline 0.6.0 → 0.6.1

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/CLAUDE.md +10 -9
  4. data/README.md +24 -10
  5. data/doc/pinned-behaviour.md +131 -30
  6. data/exe/badline-ruby +20 -24
  7. data/lib/badline/address_bus.rb +10 -1
  8. data/lib/badline/audio/cli.rb +18 -5
  9. data/lib/badline/audio/console.rb +2 -1
  10. data/lib/badline/audio.rb +0 -3
  11. data/lib/badline/computer.rb +6 -3
  12. data/lib/badline/cpu/boundary.rb +54 -0
  13. data/lib/badline/cpu/core.rb +165 -0
  14. data/lib/badline/cpu.rb +3 -214
  15. data/lib/badline/drive1541/cpu.rb +29 -0
  16. data/lib/badline/drive1541.rb +1 -0
  17. data/lib/badline/ffi.rb +157 -0
  18. data/lib/badline/frontend/app.rb +225 -0
  19. data/lib/badline/frontend/audio_sink.rb +72 -0
  20. data/lib/badline/frontend/boot.rb +52 -0
  21. data/lib/badline/frontend/controls.rb +163 -0
  22. data/lib/badline/frontend/drive_led.rb +51 -0
  23. data/lib/badline/frontend/frame_rate.rb +83 -0
  24. data/lib/badline/frontend/gamepads.rb +76 -0
  25. data/lib/badline/frontend/keys.rb +48 -0
  26. data/lib/badline/frontend/pacer.rb +100 -0
  27. data/lib/badline/frontend/pad_port.rb +61 -0
  28. data/lib/badline/frontend/screen.rb +65 -0
  29. data/lib/badline/frontend/screenshot.rb +22 -0
  30. data/lib/badline/frontend/snapshots.rb +60 -0
  31. data/lib/badline/frontend/sound.rb +153 -0
  32. data/lib/badline/frontend/timeline.rb +171 -0
  33. data/lib/badline/frontend.rb +18 -0
  34. data/lib/badline/options/event.rb +94 -0
  35. data/lib/badline/options/help.rb +78 -0
  36. data/lib/badline/options/option.rb +29 -0
  37. data/lib/badline/options/table.rb +62 -0
  38. data/lib/badline/options.rb +181 -145
  39. data/lib/badline/reu/dma/saved_state.rb +50 -0
  40. data/lib/badline/reu/dma.rb +56 -47
  41. data/lib/badline/reu/saved_state.rb +2 -1
  42. data/lib/badline/reu.rb +19 -10
  43. data/lib/badline/sdl.rb +123 -51
  44. data/lib/badline/sid/waveform/noise_writeback.rb +33 -11
  45. data/lib/badline/sid/waveform.rb +2 -3
  46. data/lib/badline/snapshot/state.rb +1 -1
  47. data/lib/badline/version.rb +1 -1
  48. data/lib/badline/vic/bank.rb +21 -0
  49. data/lib/badline/vic.rb +15 -6
  50. metadata +26 -19
  51. data/.claude/hooks/session-start.sh +0 -57
  52. data/.claude/settings.json +0 -14
  53. data/lib/badline/audio/sdl_sink.rb +0 -70
  54. data/lib/badline/audio/stream.rb +0 -123
  55. data/lib/badline/gui/application.rb +0 -243
  56. data/lib/badline/gui/drive_led_pane.rb +0 -29
  57. data/lib/badline/gui/gamepads.rb +0 -104
  58. data/lib/badline/gui/joy_map.rb +0 -27
  59. data/lib/badline/gui/key_map.rb +0 -39
  60. data/lib/badline/gui/palette.rb +0 -35
  61. data/lib/badline/gui/pane.rb +0 -36
  62. data/lib/badline/gui/screen_pane.rb +0 -46
  63. data/lib/badline/gui/snapshots.rb +0 -67
  64. data/lib/badline/gui/window.rb +0 -61
  65. data/lib/badline/gui.rb +0 -14
  66. data/lib/badline/sdl/events.rb +0 -49
  67. data/lib/badline/sdl/functions.rb +0 -74
  68. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Host game controllers wired onto the control ports: the first
6
+ # controller found drives joystick 2, the port most games read, and a
7
+ # second one joystick 1. PadPort holds the mapping.
8
+ #
9
+ # The controllers are polled every frame, and rescanned whenever SDL
10
+ # reports one added or removed.
11
+ class Gamepads
12
+ PORTS = [2, 1].freeze
13
+
14
+ def initialize(computer, verbose)
15
+ @verbose = verbose
16
+ @controllers = []
17
+ @ports = [PadPort.new(computer.joystick2), PadPort.new(computer.joystick1)]
18
+ @buttons = Array.new(PadPort::BUTTON_COUNT, 0)
19
+ @available = SDL.SDL_InitSubSystem(SDL::INIT_GAMECONTROLLER).zero?
20
+ puts "No game controllers: #{SDL.SDL_GetError}" if @verbose && !@available
21
+ end
22
+
23
+ # Wires the controllers to another machine's joysticks, which take
24
+ # what they hold on the next poll.
25
+ def computer=(computer)
26
+ @ports.each(&:release)
27
+ @ports = [PadPort.new(computer.joystick2), PadPort.new(computer.joystick1)]
28
+ end
29
+
30
+ def rescan
31
+ return unless @available
32
+
33
+ close
34
+ index = 0
35
+ count = SDL.SDL_NumJoysticks
36
+ while index < count && @controllers.size < PORTS.size
37
+ open_controller(index) if SDL.SDL_IsGameController(index) != 0
38
+ index += 1
39
+ end
40
+ end
41
+
42
+ def poll
43
+ @controllers.each_with_index { |controller, index| @ports[index].update(active(controller)) }
44
+ end
45
+
46
+ def close
47
+ @controllers.each { |controller| SDL.SDL_GameControllerClose(controller) }
48
+ @controllers = []
49
+ @ports.each(&:release)
50
+ end
51
+
52
+ private
53
+
54
+ def open_controller(index)
55
+ controller = SDL.SDL_GameControllerOpen(index)
56
+ # SDL returns NULL when the controller won't open, which Spinel's FFI
57
+ # reads as nil.
58
+ return if controller.nil?
59
+
60
+ puts "Gamepad on joystick #{PORTS[@controllers.size]}: #{SDL.SDL_GameControllerName(controller)}" if @verbose
61
+ @controllers << controller
62
+ end
63
+
64
+ def active(controller)
65
+ buttons = @buttons
66
+ button = 0
67
+ while button < PadPort::BUTTON_COUNT
68
+ buttons[button] = SDL.SDL_GameControllerGetButton(controller, button)
69
+ button += 1
70
+ end
71
+ PadPort.active(buttons, SDL.SDL_GameControllerGetAxis(controller, PadPort::LEFT_X),
72
+ SDL.SDL_GameControllerGetAxis(controller, PadPort::LEFT_Y))
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # SDL scancodes follow key positions on a US layout. They map onto the C64
6
+ # keys and, in joystick mode, the joystick directions.
7
+ module Keys
8
+ TAB = 43
9
+
10
+ LETTERS = %i[a b c d e f g h i j k l m n o p q r s t u v w x y z].freeze
11
+ DIGITS = %i[1 2 3 4 5 6 7 8 9 0].freeze
12
+
13
+ OTHERS = {
14
+ 40 => :return, 41 => :run_stop, 42 => :delete, 44 => :space,
15
+ 45 => :-, 46 => :"=", 49 => :"@", 51 => :";", 52 => :":",
16
+ 48 => :up, 53 => :left, 54 => :",", 55 => :".", 56 => :/,
17
+ 58 => :f1, 60 => :f3, 62 => :f5, 64 => :f7,
18
+ 74 => :clr_home, 75 => :restore, 77 => :£, 79 => :cursor_h, 80 => :cursor_left,
19
+ 81 => :cursor_v, 82 => :cursor_up, 85 => :*, 87 => :+,
20
+ 224 => :control, 225 => :lshift, 226 => :cbm, 229 => :rshift
21
+ }.freeze
22
+
23
+ F9 = 66
24
+ F10 = 67
25
+ F11 = 68
26
+ F12 = 69
27
+
28
+ ARROWS = { 44 => :fire, 228 => :fire, 79 => :right, 80 => :left, 81 => :down, 82 => :up }.freeze
29
+ WASD = { 225 => :fire, 7 => :right, 4 => :left, 22 => :down, 26 => :up }.freeze
30
+
31
+ def self.c64_key(scancode)
32
+ if scancode.between?(4, 29)
33
+ LETTERS[scancode - 4]
34
+ elsif scancode.between?(30, 39)
35
+ DIGITS[scancode - 30]
36
+ else
37
+ OTHERS[scancode] || :none
38
+ end
39
+ end
40
+
41
+ def self.arrows(scancode) = ARROWS[scancode] || :none
42
+
43
+ def self.wasd(scancode) = WASD[scancode] || :none
44
+
45
+ def self.joystick?(scancode) = ARROWS.key?(scancode) || WASD.key?(scancode)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Decides how many cycles each frame clocks and how long it waits.
6
+ #
7
+ # With vsync, a frame lasts one display refresh (see FrameRate), and
8
+ # presenting it waits for the display, so #wait has nothing left to do.
9
+ # While the sound plays, each frame's cycles are trimmed to keep the
10
+ # audio queue at Sound::AHEAD. If presenting doesn't wait, as when the
11
+ # display's driver has vsync off, #check falls back to the timer, and
12
+ # until then the sound holds any frame that would take its queue past
13
+ # SAFETY, so no samples are dropped. #measure fits the frames to the
14
+ # rate the display presents at, measured over the whole run.
15
+ #
16
+ # Without vsync, a frame is one of the machine's frames, 20 ms on PAL.
17
+ # With the sound playing, #wait lasts until the audio queue is down to
18
+ # Sound::AHEAD; otherwise it waits out the rest of the frame.
19
+ class Pacer
20
+ # The frame after which the app first checks that vsync holds.
21
+ EARLY_CHECK = 10
22
+ SAFETY = Sound::AHEAD * 2
23
+
24
+ attr_reader :rate
25
+
26
+ def initialize(paced:, vsync:, verbose:, region: Region::PAL)
27
+ @paced = paced
28
+ @verbose = verbose
29
+ @vsync = paced && vsync
30
+ @region = region
31
+ @rate = FrameRate.machine(region)
32
+ @deadline = 0.0
33
+ @measured_frames = 0
34
+ @measured_seconds = 0.0
35
+ end
36
+
37
+ def vsync? = @vsync
38
+
39
+ # Fits the frames to the display's refresh rate, once the window has
40
+ # been opened with vsync.
41
+ def fit(refresh)
42
+ @rate = FrameRate.display(refresh, @region.clock_hz)
43
+ puts "Display #{@rate.refresh} Hz, vsync: #{@rate.base_cycles} cycles a frame" if @verbose
44
+ end
45
+
46
+ def start(at)
47
+ @deadline = at
48
+ end
49
+
50
+ def cycles(sound)
51
+ return @rate.base_cycles unless @vsync && sound.playing?
52
+
53
+ @rate.cycles(sound.level, Sound::AHEAD)
54
+ end
55
+
56
+ def wait(sound)
57
+ return unless @paced
58
+
59
+ if sound.playing?
60
+ sound.wait(@vsync ? SAFETY : Sound::AHEAD)
61
+ elsif !@vsync
62
+ wait_for_timer
63
+ end
64
+ end
65
+
66
+ # Adds `frames` presented over `elapsed` seconds to the measure of the
67
+ # display's rate, and fits the frames to the rate measured so far.
68
+ def measure(frames, elapsed)
69
+ return unless @vsync
70
+
71
+ @measured_frames += frames
72
+ @measured_seconds += elapsed
73
+ @rate.refit(@measured_frames, @measured_seconds)
74
+ end
75
+
76
+ # Falls back to the timer if the last `frames`, presented over
77
+ # `elapsed` seconds up to `at`, didn't wait for the display.
78
+ def check(frames, elapsed, at)
79
+ return if !@vsync || @rate.vsync_holds?(frames, elapsed)
80
+
81
+ puts "Vsync doesn't hold the display's #{@rate.refresh} Hz, so a timer paces the frames instead." if @verbose
82
+ @vsync = false
83
+ @deadline = at
84
+ end
85
+
86
+ private
87
+
88
+ def wait_for_timer
89
+ @deadline += @rate.seconds
90
+ started = now
91
+ @deadline = started if @deadline < started - @rate.seconds
92
+ left = @deadline - started
93
+ SDL.SDL_Delay((left * 1000).to_i) if left > 0.001
94
+ nil while now < @deadline
95
+ end
96
+
97
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # One game controller's state wired onto a joystick: the D-pad and the
6
+ # left stick both steer, and every face and shoulder button fires. It
7
+ # only releases directions it pressed itself, so the keyboard's joystick
8
+ # keys keep working with a controller connected.
9
+ class PadPort
10
+ DEADZONE = 8_000
11
+
12
+ # SDL_GameControllerButton values.
13
+ BUTTON_COUNT = 15
14
+ BUTTONS = {
15
+ up: [11], down: [12], left: [13], right: [14],
16
+ fire: [0, 1, 2, 3, 9, 10] # A, B, X, Y and the shoulders
17
+ }.freeze
18
+
19
+ # SDL_GameControllerAxis values, and the sign that steers each way.
20
+ LEFT_X = 0
21
+ LEFT_Y = 1
22
+ AXES = { up: [LEFT_Y, -1], down: [LEFT_Y, 1], left: [LEFT_X, -1], right: [LEFT_X, 1] }.freeze
23
+
24
+ DIRECTIONS = %i[up down left right fire].freeze
25
+
26
+ attr_reader :pressed
27
+
28
+ def initialize(joystick)
29
+ @joystick = joystick
30
+ @pressed = []
31
+ end
32
+
33
+ # The directions a controller holds, from its `buttons` (0 or 1 for
34
+ # each SDL button) and its left stick.
35
+ def self.active(buttons, left_x, left_y)
36
+ DIRECTIONS.select do |direction|
37
+ BUTTONS[direction].any? { |button| buttons[button] != 0 } ||
38
+ tilted?(direction, left_x, left_y)
39
+ end
40
+ end
41
+
42
+ def self.tilted?(direction, left_x, left_y)
43
+ return false if direction == :fire
44
+
45
+ axis, sign = AXES[direction]
46
+ (axis == LEFT_X ? left_x : left_y) * sign > DEADZONE
47
+ end
48
+
49
+ # Presses what the controller holds and releases what it let go of.
50
+ def update(active)
51
+ @pressed.each { |direction| @joystick.release(direction) unless active.include?(direction) }
52
+ active.each { |direction| @joystick.press(direction) }
53
+ @pressed = active
54
+ end
55
+
56
+ def release
57
+ update([])
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The visible part of the VIC's display, the crop of the machine's
6
+ # region, repacked for an SDL texture in XRGB8888. Spinel hands an
7
+ # Array of Integers to C as 64-bit words, as badline/ffi does on CRuby,
8
+ # so each word carries two neighbouring pixels, the left one in the low
9
+ # half. The texture is PAL's crop, 384x272, and a region with fewer
10
+ # lines to show sits in the middle of it, between black bands.
11
+ class Screen
12
+ WIDTH = Region::PAL.crop[2]
13
+ HEIGHT = Region::PAL.crop[3]
14
+ ROW_BYTES = WIDTH * 4
15
+ ROW_WORDS = WIDTH / 2
16
+
17
+ COLORS = [
18
+ 0x000000, 0xffffff, 0x924a40, 0x84c5cc,
19
+ 0x9351b6, 0x72b14b, 0x483aaa, 0xd5df7c,
20
+ 0x675200, 0xc33d00, 0xc18178, 0x606060,
21
+ 0x8a8a8a, 0xb3ec91, 0x867ade, 0xb3b3b3
22
+ ].freeze
23
+
24
+ PAIRS = Array.new(256) { |pair| COLORS[pair & 0x0f] | (COLORS[pair >> 4] << 32) }.freeze
25
+
26
+ attr_reader :pixels
27
+
28
+ def initialize(vic)
29
+ @vic = vic
30
+ crop = vic.region.crop
31
+ @col_offset = crop[0]
32
+ @row_offset = crop[1]
33
+ @height = crop[3]
34
+ @band = (HEIGHT - @height) / 2
35
+ @pixels = Array.new(HEIGHT * ROW_WORDS, 0)
36
+ end
37
+
38
+ # Repacks only the lines the VIC has changed since the last frame.
39
+ def update
40
+ dirty = @vic.dirty_lines
41
+ row = 0
42
+ while row < @height
43
+ pack_row(row) if dirty[row + @row_offset]
44
+ row += 1
45
+ end
46
+ @vic.clear_dirty_lines!
47
+ end
48
+
49
+ private
50
+
51
+ def pack_row(row)
52
+ display = @vic.display
53
+ pixels = @pixels
54
+ from = ((row + @row_offset) * @vic.width) + @col_offset
55
+ to = (row + @band) * ROW_WORDS
56
+ last = to + ROW_WORDS
57
+ while to < last
58
+ pixels[to] = PAIRS[(display[from] & 0x0f) | ((display[from + 1] & 0x0f) << 4)]
59
+ from += 2
60
+ to += 1
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # --screenshot's frame: what the renderer drew, read back before it is
6
+ # presented and saved as a BMP.
7
+ module Screenshot
8
+ def self.write(renderer, path)
9
+ SDL.SDL_GetRendererOutputSize(renderer, SDL.output_w, SDL.output_h)
10
+ width = SDL.read_i32(SDL.output_w)
11
+ height = SDL.read_i32(SDL.output_h)
12
+ data = LibC.malloc(width * height * 4)
13
+ SDL.SDL_RenderReadPixels(renderer, nil, SDL::PIXELFORMAT_RGB888, data, width * 4)
14
+ surface = SDL.SDL_CreateRGBSurfaceWithFormatFrom(data, width, height, 32, width * 4, SDL::PIXELFORMAT_RGB888)
15
+ SDL.SDL_SaveBMP_RW(surface, SDL.SDL_RWFromFile(path, "wb"), 1)
16
+ SDL.SDL_FreeSurface(surface)
17
+ LibC.free(data)
18
+ puts "wrote #{path}, #{width}x#{height}"
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The window's snapshots: F11 saves the machine to a new
6
+ # badline-<date>-<time>.vsf in the working directory, and F12 goes back
7
+ # to the snapshot last saved or opened, in a new machine. --save-snapshot
8
+ # saves one after the last frame.
9
+ class Snapshots
10
+ KEYS = [Keys::F11, Keys::F12].freeze
11
+
12
+ # The machine running, which F12 replaces with the restored one.
13
+ attr_reader :computer
14
+
15
+ def initialize(computer, options)
16
+ @computer = computer
17
+ @last = options.snapshot? ? options.media_path.to_s : ""
18
+ @at_finish = options.save_snapshot
19
+ end
20
+
21
+ # Saves or restores for F11 or F12, and says whether a restored
22
+ # machine now runs in place of the one before.
23
+ def key(scancode)
24
+ return restore if scancode == Keys::F12
25
+
26
+ save if scancode == Keys::F11
27
+ false
28
+ end
29
+
30
+ # Saves the machine, and warns when it can't.
31
+ def save(path = "badline-#{Time.now.strftime('%Y%m%d-%H%M%S')}.vsf")
32
+ @computer.save_snapshot(path)
33
+ @last = path
34
+ puts "Saved #{path}"
35
+ rescue Snapshot::FormatError, SystemCallError => e
36
+ warn "badline: #{path}: #{e.message}"
37
+ end
38
+
39
+ # Restores the snapshot last saved or opened into a new machine, and
40
+ # warns when it fails, leaving the machine running as it was.
41
+ def restore
42
+ if @last.empty?
43
+ puts "No snapshot to restore, F11 saves one"
44
+ return false
45
+ end
46
+
47
+ @computer = Snapshot.load(@last) { |line| puts line }
48
+ puts "Restored #{@last}"
49
+ true
50
+ rescue Snapshot::FormatError, SystemCallError => e
51
+ warn "badline: #{@last}: #{e.message}"
52
+ false
53
+ end
54
+
55
+ def finish
56
+ save(@at_finish) unless @at_finish.empty?
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID's output on SDL's audio queue, as mono signed 16-bit samples.
6
+ # Once the device plays, it is the clock: #wait holds each frame until
7
+ # the queue is down to `AHEAD`, so the machine runs at the device's pace.
8
+ #
9
+ # The device starts once the queue holds AHEAD. Below real time the
10
+ # queue runs dry, and the device then stops until it holds AHEAD again,
11
+ # so the sound stutters with silent gaps rather than slowing down. A
12
+ # frame that would take the queue past LIMIT is dropped whole, which
13
+ # only happens unpaced.
14
+ #
15
+ # Each frame's samples go to SDL through an IO::Buffer of 16-bit values.
16
+ class Sound
17
+ RATE = 44_100
18
+ AHEAD = 0.08
19
+ LIMIT = 0.25
20
+
21
+ attr_reader :rate, :underruns, :dropped, :queued, :low, :high
22
+
23
+ def initialize(sid, wanted, verbose)
24
+ @sid = sid
25
+ @verbose = verbose
26
+ @device = 0
27
+ @rate = RATE
28
+ @started = false
29
+ @muted = false
30
+ @underruns = 0
31
+ @dropped = 0
32
+ @queued = 0
33
+ @buffer = IO::Buffer.new(4096)
34
+ reset_levels
35
+ open_device if wanted
36
+ end
37
+
38
+ def on? = @device != 0
39
+
40
+ # Plays another machine's SID from here on.
41
+ def sid=(sid)
42
+ @sid = sid
43
+ sid.record(rate: @rate) if on?
44
+ end
45
+
46
+ def muted? = @muted
47
+
48
+ def playing? = @started
49
+
50
+ def feed
51
+ return unless on?
52
+
53
+ samples = @sid.drain_samples
54
+ return if @muted || samples.empty?
55
+
56
+ level = queued_seconds
57
+ if level + (samples.size.to_f / @rate) > LIMIT
58
+ @dropped += samples.size
59
+ else
60
+ play(samples, level)
61
+ end
62
+ end
63
+
64
+ # How much the audio queue holds, in seconds.
65
+ def level = queued_seconds
66
+
67
+ # Waits until the queue is down to `level` seconds.
68
+ def wait(level = AHEAD)
69
+ SDL.SDL_Delay(1) while queued_seconds > level
70
+ end
71
+
72
+ def toggle_mute
73
+ @muted = !@muted
74
+ return unless @muted
75
+
76
+ halt
77
+ SDL.SDL_ClearQueuedAudio(@device)
78
+ end
79
+
80
+ def reset_levels
81
+ @low = LIMIT
82
+ @high = 0.0
83
+ end
84
+
85
+ def close
86
+ SDL.SDL_CloseAudioDevice(@device) if on?
87
+ end
88
+
89
+ private
90
+
91
+ def open_device
92
+ unless SDL.SDL_InitSubSystem(SDL::INIT_AUDIO).zero?
93
+ puts "No sound: #{SDL.SDL_GetError}"
94
+ return
95
+ end
96
+
97
+ SDL.spec_freq(SDL.wanted, RATE)
98
+ SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
99
+ SDL.spec_channels(SDL.wanted, 1)
100
+ SDL.spec_samples(SDL.wanted, 512)
101
+ @device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, SDL::ALLOW_FREQUENCY_CHANGE)
102
+ return puts "No sound: #{SDL.SDL_GetError}" unless on?
103
+
104
+ @rate = SDL.read_i32(SDL.obtained)
105
+ @sid.record(rate: @rate)
106
+ puts "Sound at #{@rate} Hz" if @verbose
107
+ end
108
+
109
+ def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
110
+
111
+ def play(samples, level)
112
+ @low = level if level < @low
113
+ underrun! if @started && level.zero?
114
+ queue(samples)
115
+ level = queued_seconds
116
+ @high = level if level > @high
117
+ start if level >= AHEAD
118
+ end
119
+
120
+ def queue(samples)
121
+ count = samples.size
122
+ bytes = count * 2
123
+ @buffer.resize(bytes) if bytes > @buffer.size
124
+ buffer = @buffer
125
+ i = 0
126
+ while i < count
127
+ buffer.set_value(:s16, i * 2, samples[i])
128
+ i += 1
129
+ end
130
+ SDL.SDL_QueueAudio(@device, buffer, bytes)
131
+ @queued += count
132
+ end
133
+
134
+ def start
135
+ return if @started
136
+
137
+ @started = true
138
+ SDL.SDL_PauseAudioDevice(@device, 0)
139
+ end
140
+
141
+ def halt
142
+ @started = false
143
+ SDL.SDL_PauseAudioDevice(@device, 1)
144
+ end
145
+
146
+ def underrun!
147
+ @underruns += 1
148
+ puts "Running below real time, so the sound will stutter." if @underruns == 1
149
+ halt
150
+ end
151
+ end
152
+ end
153
+ end