badline 0.1.0 → 0.2.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +185 -0
  3. data/CLAUDE.md +217 -0
  4. data/CODE_OF_CONDUCT.md +92 -0
  5. data/CONTRIBUTING.md +51 -0
  6. data/README.md +224 -31
  7. data/doc/pinned-behaviour.md +1117 -0
  8. data/exe/badline +22 -2
  9. data/exe/badline-sid +34 -0
  10. data/lib/badline/address_bus.rb +96 -25
  11. data/lib/badline/audio/aiff.rb +36 -0
  12. data/lib/badline/audio/bare_player.rb +129 -0
  13. data/lib/badline/audio/cli.rb +133 -0
  14. data/lib/badline/audio/console.rb +75 -0
  15. data/lib/badline/audio/jukebox.rb +82 -0
  16. data/lib/badline/audio/machine_player.rb +57 -0
  17. data/lib/badline/audio/options.rb +137 -0
  18. data/lib/badline/audio/pcm_writer.rb +57 -0
  19. data/lib/badline/audio/playback.rb +108 -0
  20. data/lib/badline/audio/renderer.rb +85 -0
  21. data/lib/badline/audio/sdl_sink.rb +102 -0
  22. data/lib/badline/audio/wav.rb +23 -0
  23. data/lib/badline/audio.rb +14 -0
  24. data/lib/badline/cartridge/action_replay.rb +111 -0
  25. data/lib/badline/cartridge/atomic_power.rb +24 -0
  26. data/lib/badline/cartridge/bank.rb +81 -0
  27. data/lib/badline/cartridge/comal80.rb +48 -0
  28. data/lib/badline/cartridge/dinamic.rb +34 -0
  29. data/lib/badline/cartridge/easy_flash.rb +122 -0
  30. data/lib/badline/cartridge/epyx_fastload.rb +71 -0
  31. data/lib/badline/cartridge/final_cartridge3.rb +77 -0
  32. data/lib/badline/cartridge/flash.rb +265 -0
  33. data/lib/badline/cartridge/freezer.rb +31 -0
  34. data/lib/badline/cartridge/fun_play.rb +38 -0
  35. data/lib/badline/cartridge/g_mod2.rb +62 -0
  36. data/lib/badline/cartridge/game_system.rb +40 -0
  37. data/lib/badline/cartridge/kcs_power.rb +72 -0
  38. data/lib/badline/cartridge/mach5.rb +34 -0
  39. data/lib/badline/cartridge/magic_desk.rb +6 -3
  40. data/lib/badline/cartridge/ocean.rb +11 -12
  41. data/lib/badline/cartridge/pagefox.rb +56 -0
  42. data/lib/badline/cartridge/retro_replay.rb +276 -0
  43. data/lib/badline/cartridge/rex_utility.rb +33 -0
  44. data/lib/badline/cartridge/rgcd.rb +48 -0
  45. data/lib/badline/cartridge/simons_basic.rb +40 -0
  46. data/lib/badline/cartridge/standard.rb +3 -19
  47. data/lib/badline/cartridge/super_games.rb +36 -0
  48. data/lib/badline/cartridge/westermann.rb +34 -0
  49. data/lib/badline/cartridge/zaxxon.rb +50 -0
  50. data/lib/badline/cartridge.rb +132 -16
  51. data/lib/badline/cia/interrupt_register.rb +102 -0
  52. data/lib/badline/cia/serial.rb +219 -0
  53. data/lib/badline/cia/timer.rb +43 -31
  54. data/lib/badline/cia.rb +138 -88
  55. data/lib/badline/color_memory.rb +14 -5
  56. data/lib/badline/computer.rb +97 -12
  57. data/lib/badline/control_ports.rb +54 -9
  58. data/lib/badline/cpu/addressing.rb +131 -0
  59. data/lib/badline/cpu/microcode.rb +169 -0
  60. data/lib/badline/cpu/operations.rb +88 -0
  61. data/lib/badline/cpu/stack_operations.rb +82 -0
  62. data/lib/badline/cpu.rb +109 -168
  63. data/lib/badline/cycleable.rb +3 -0
  64. data/lib/badline/datasette.rb +81 -0
  65. data/lib/badline/debug_register.rb +28 -0
  66. data/lib/badline/gui/application.rb +79 -18
  67. data/lib/badline/gui/gamepads.rb +97 -0
  68. data/lib/badline/gui/joy_map.rb +15 -7
  69. data/lib/badline/gui.rb +1 -0
  70. data/lib/badline/input/mouse1351.rb +41 -0
  71. data/lib/badline/input/paddles.rb +36 -0
  72. data/lib/badline/input.rb +4 -0
  73. data/lib/badline/instruction_set/arithmetic.rb +16 -21
  74. data/lib/badline/instruction_set/bitwise.rb +17 -22
  75. data/lib/badline/instruction_set/branch.rb +20 -19
  76. data/lib/badline/instruction_set/flag.rb +7 -7
  77. data/lib/badline/instruction_set/illegal.rb +14 -22
  78. data/lib/badline/instruction_set/inc_dec.rb +8 -10
  79. data/lib/badline/instruction_set/stack.rb +5 -57
  80. data/lib/badline/instruction_set/transfer.rb +9 -9
  81. data/lib/badline/instruction_set.rb +9 -31
  82. data/lib/badline/interrupts.rb +104 -0
  83. data/lib/badline/joystick.rb +9 -9
  84. data/lib/badline/kernal_trap/channel.rb +51 -0
  85. data/lib/badline/kernal_trap/drive/status.rb +49 -0
  86. data/lib/badline/kernal_trap/drive.rb +277 -0
  87. data/lib/badline/kernal_trap/file.rb +5 -25
  88. data/lib/badline/kernal_trap/load.rb +86 -24
  89. data/lib/badline/kernal_trap/routine.rb +51 -0
  90. data/lib/badline/kernal_trap/save.rb +46 -14
  91. data/lib/badline/kernal_trap/serial.rb +171 -0
  92. data/lib/badline/kernal_trap.rb +4 -0
  93. data/lib/badline/keyboard.rb +40 -16
  94. data/lib/badline/media.rb +48 -10
  95. data/lib/badline/memory.rb +5 -0
  96. data/lib/badline/roms/eapi/LICENSE.md +15 -0
  97. data/lib/badline/roms/eapi/README +41 -0
  98. data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
  99. data/lib/badline/sid/decimator.rb +46 -0
  100. data/lib/badline/sid/envelope.rb +232 -0
  101. data/lib/badline/sid/filter.rb +213 -0
  102. data/lib/badline/sid/voice.rb +61 -0
  103. data/lib/badline/sid/waveform/combined.rb +126 -0
  104. data/lib/badline/sid/waveform/fast_forward.rb +105 -0
  105. data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
  106. data/lib/badline/sid/waveform.rb +289 -0
  107. data/lib/badline/sid.rb +274 -7
  108. data/lib/badline/status.rb +251 -23
  109. data/lib/badline/storage/crt_file.rb +18 -2
  110. data/lib/badline/storage/d64_image.rb +6 -0
  111. data/lib/badline/storage/d71_image.rb +4 -0
  112. data/lib/badline/storage/d81_image.rb +4 -0
  113. data/lib/badline/storage/disk_image.rb +99 -13
  114. data/lib/badline/storage/host_directory.rb +30 -4
  115. data/lib/badline/storage/sid_file.rb +274 -0
  116. data/lib/badline/storage/song_lengths.rb +65 -0
  117. data/lib/badline/storage/t64.rb +67 -0
  118. data/lib/badline/storage/tap.rb +68 -0
  119. data/lib/badline/storage.rb +19 -0
  120. data/lib/badline/time_of_day.rb +105 -52
  121. data/lib/badline/version.rb +1 -1
  122. data/lib/badline/vic/bank.rb +14 -6
  123. data/lib/badline/vic/border_mask.rb +76 -0
  124. data/lib/badline/vic/collisions.rb +104 -0
  125. data/lib/badline/vic/color_patches.rb +33 -0
  126. data/lib/badline/vic/display_state.rb +92 -29
  127. data/lib/badline/vic/graphics_mode.rb +78 -53
  128. data/lib/badline/vic/graphics_shifter.rb +140 -0
  129. data/lib/badline/vic/register_log.rb +63 -0
  130. data/lib/badline/vic/registers.rb +3 -0
  131. data/lib/badline/vic/sequencer.rb +163 -137
  132. data/lib/badline/vic/sequencer_output.rb +116 -0
  133. data/lib/badline/vic/sprite/internal_bus.rb +49 -0
  134. data/lib/badline/vic/sprite/shifter.rb +129 -0
  135. data/lib/badline/vic/sprite.rb +275 -65
  136. data/lib/badline/vic/sprites.rb +220 -58
  137. data/lib/badline/vic.rb +389 -55
  138. data/lib/badline.rb +5 -0
  139. data/vendor/.gitkeep +0 -0
  140. metadata +104 -4
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # Plays a tune's songs interactively: the console's keys step between
6
+ # songs, pause and quit, and the status line follows along. It stops
7
+ # when a song ends, on q, or on Ctrl-C.
8
+ #
9
+ # `renderer` builds the renderer for a song at the sink's rate, and
10
+ # `length` gives a song's length in seconds.
11
+ class Jukebox
12
+ def initialize(sink, console, songs:, renderer:, length:)
13
+ @sink = sink
14
+ @console = console
15
+ @songs = songs
16
+ @renderer = renderer
17
+ @length = length
18
+ @below = false
19
+ end
20
+
21
+ # Returns the result of the last song's Playback#play.
22
+ def run(song)
23
+ loop do
24
+ @action = nil
25
+ result = play(song)
26
+ song = following(song)
27
+ return result unless song
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def play(song)
34
+ @song = song
35
+ @elapsed = 0.0
36
+ @playback = Playback.new(@sink, sleeper: ->(seconds) { react(@console.wait(seconds)) },
37
+ on_underrun: -> { @below = true })
38
+ draw
39
+ @playback.play(@renderer.call(song, @sink.rate)) do |played|
40
+ @elapsed = played
41
+ react(@console.wait(0))
42
+ draw
43
+ end
44
+ end
45
+
46
+ def following(song)
47
+ case @action
48
+ when :next then song + 1
49
+ when :previous then song - 1
50
+ end
51
+ end
52
+
53
+ def react(actions)
54
+ actions.each do |action|
55
+ case action
56
+ when :pause then toggle_pause
57
+ when :next then skip(:next) if @song < @songs
58
+ when :previous then skip(:previous) if @song > 1
59
+ when :quit then skip(:quit)
60
+ end
61
+ end
62
+ end
63
+
64
+ def skip(action)
65
+ @action = action
66
+ @playback.stop
67
+ end
68
+
69
+ def toggle_pause
70
+ @playback.paused? ? @playback.resume : @playback.pause
71
+ draw
72
+ end
73
+
74
+ def draw
75
+ notes = []
76
+ notes << "paused" if @playback.paused?
77
+ notes << "below real time" if @below
78
+ @console.status(song: @song, songs: @songs, elapsed: @elapsed, length: @length.call(@song), notes:)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # Runs a tune on the whole machine, for the RSID tunes that install their
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.
9
+ class MachinePlayer
10
+ FRAME_CYCLES = BarePlayer::FRAME_CYCLES
11
+
12
+ # Boot, plus room for the keyboard buffer to type the SYS.
13
+ START_LIMIT = 15_000_000
14
+
15
+ def initialize(tune, song: nil, sid_model: tune.sid_model)
16
+ @tune = tune
17
+ @song = song || tune.start_song
18
+ @computer = Computer.new(sid_model:)
19
+ @injected = false
20
+ @started = false
21
+ end
22
+
23
+ def start
24
+ @computer.on_init { inject }
25
+ @computer.cpu.install_trap(@tune.driver_address) { begin_playing }
26
+ @computer.cycle! until @started || @computer.cycles > START_LIMIT
27
+ end
28
+
29
+ def sid = @computer.sid
30
+
31
+ # Advances one PAL frame, or `budget` cycles if that is shorter, then
32
+ # yields whatever the SID recorded over it. Returns the cycles advanced.
33
+ def frame(budget, &)
34
+ cycles = [budget, FRAME_CYCLES].min
35
+ cycles.times { @computer.cycle! }
36
+ sid.drain_samples.each(&)
37
+ cycles
38
+ end
39
+
40
+ private
41
+
42
+ def inject
43
+ @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")
46
+ @injected = true
47
+ end
48
+
49
+ def begin_playing
50
+ return unless @injected
51
+
52
+ @computer.sid.synthesize!
53
+ @started = true
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Badline
6
+ module Audio
7
+ # The command line of `badline-sid`. It loads nothing else from badline,
8
+ # so the executable can parse and reject its arguments before paying for
9
+ # the emulator.
10
+ class Options
11
+ class Error < StandardError; end
12
+
13
+ FALLBACK_SECONDS = 60.0
14
+
15
+ DEFAULT_RATE = 44_100
16
+
17
+ SID_MODELS = { "6581" => :mos6581, "8580" => :mos8580 }.freeze
18
+
19
+ BANNER = <<~BANNER.freeze
20
+ Usage: badline-sid [options] tune.sid [-o output]
21
+
22
+ Plays a .sid tune on the host's audio device, or with an output file
23
+ renders it to 16-bit PCM instead. The container follows the output
24
+ file's extension, .wav or .aiff.
25
+
26
+ A .sid file carries no length of its own. Without --seconds the tune is
27
+ looked up by MD5 in HVSC's Songlengths.md5, taken from --songlengths, from
28
+ the DOCUMENTS directory of an HVSC collection above the tune, or from
29
+ $HVSC_BASE. Failing all of those it runs for #{FALLBACK_SECONDS.to_i} seconds.
30
+
31
+ Played on a terminal, n or → skips to the next song, p or ← to the
32
+ previous one, space pauses and q quits.
33
+
34
+ PSID tunes run on a bare CPU and SID, faster than real time. RSID
35
+ tunes boot the whole machine and run at about half real time, so
36
+ they stutter when played. --filter-chunk 1 runs the filter cycle by
37
+ cycle, which is exact but takes about twice as long.
38
+
39
+ Options:
40
+ BANNER
41
+
42
+ attr_reader :tune_path, :output, :song, :seconds, :songlengths, :sid_model, :filter_chunk
43
+
44
+ def self.parse(argv) = new.parse(argv)
45
+
46
+ def initialize
47
+ @jit = true
48
+ @quiet = false
49
+ @tui = true
50
+ @help = false
51
+ end
52
+
53
+ def parse(argv)
54
+ arguments = parser.parse(argv)
55
+ @tune_path, positional_output, *extra = arguments
56
+ raise Error, "unexpected argument: #{extra.first}" if extra.any?
57
+
58
+ @output ||= positional_output
59
+ validate unless help?
60
+ self
61
+ rescue OptionParser::ParseError => e
62
+ raise Error, e.message
63
+ end
64
+
65
+ def render? = !output.nil?
66
+
67
+ def rate = @rate || DEFAULT_RATE
68
+
69
+ def rate_given? = !@rate.nil?
70
+
71
+ def help = parser.help
72
+
73
+ def help? = @help
74
+
75
+ def quiet? = @quiet
76
+
77
+ def tui? = @tui
78
+
79
+ def jit? = @jit
80
+
81
+ private
82
+
83
+ def validate
84
+ raise Error, "no tune given" unless tune_path
85
+
86
+ validate_numbers
87
+ validate_paths
88
+ end
89
+
90
+ def validate_numbers
91
+ raise Error, "invalid argument: --song #{song}" if song&.< 1
92
+ raise Error, "invalid argument: --seconds #{seconds}" unless seconds.nil? || seconds.positive?
93
+ raise Error, "invalid argument: --rate #{rate}" unless rate.positive?
94
+ raise Error, "invalid argument: --filter-chunk #{filter_chunk}" if filter_chunk&.< 1
95
+ end
96
+
97
+ def validate_paths
98
+ [tune_path, songlengths].compact.each do |path|
99
+ raise Error, "no such file: #{path}" unless File.exist?(path)
100
+ end
101
+ end
102
+
103
+ def parser
104
+ @parser ||= OptionParser.new do |opts|
105
+ opts.banner = BANNER
106
+ define_tune_options(opts)
107
+ define_output_options(opts)
108
+ define_run_options(opts)
109
+ end
110
+ end
111
+
112
+ def define_tune_options(opts)
113
+ opts.on("-s", "--song N", Integer, "Subtune to play, from 1 (default: the tune's own)") { |n| @song = n }
114
+ opts.on("--seconds N", Float, "Length to play (default: the tune's own)") { |n| @seconds = n }
115
+ opts.on("--songlengths PATH", "HVSC Songlengths.md5 to take the length from") { |path| @songlengths = path }
116
+ opts.on("--sid MODEL", SID_MODELS,
117
+ "SID to play on: 6581 or 8580 (default: the tune's own, else 6581)") { |model| @sid_model = model }
118
+ end
119
+
120
+ def define_output_options(opts)
121
+ opts.on("-o", "--output PATH", "Render to a .wav or .aiff file instead of playing") { |path| @output = path }
122
+ opts.on("--rate HZ", Integer,
123
+ "Sample rate (default: #{DEFAULT_RATE}, or the audio device's own)") { |n| @rate = n }
124
+ opts.on("--filter-chunk N", Integer, "Filter step in cycles, 1 is exact (default: 4)") do |n|
125
+ @filter_chunk = n
126
+ end
127
+ end
128
+
129
+ def define_run_options(opts)
130
+ opts.on("--quiet", "Don't report progress") { @quiet = true }
131
+ opts.on("--no-tui", "Play without the interactive display, even on a terminal") { @tui = false }
132
+ opts.on("--disable-jit", "Run without enabling YJIT") { @jit = false }
133
+ opts.on("-h", "--help", "Show this help") { @help = true }
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # Streams mono signed 16-bit samples into a container, buffering them and
6
+ # patching the header's length fields once the stream ends.
7
+ class PCMWriter
8
+ BUFFER = 4096
9
+
10
+ attr_reader :rate, :samples
11
+
12
+ def self.open(path, rate:)
13
+ File.open(path, "wb") do |io|
14
+ writer = new(io, rate:)
15
+ yield writer
16
+ writer.finish
17
+ end
18
+ end
19
+
20
+ def initialize(io, rate:)
21
+ @io = io
22
+ @rate = rate
23
+ @samples = 0
24
+ @buffer = []
25
+ @io.write(header)
26
+ end
27
+
28
+ def <<(sample)
29
+ @buffer << sample
30
+ flush if @buffer.length >= BUFFER
31
+ self
32
+ end
33
+
34
+ def finish
35
+ flush
36
+ patch
37
+ end
38
+
39
+ private
40
+
41
+ def flush
42
+ return if @buffer.empty?
43
+
44
+ @io.write(@buffer.pack(pack_format))
45
+ @samples += @buffer.length
46
+ @buffer.clear
47
+ end
48
+
49
+ def data_size = @samples * 2
50
+
51
+ def write_at(offset, bytes)
52
+ @io.seek(offset)
53
+ @io.write(bytes)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # Plays a renderer's frames through a sink in real time. The queue is
6
+ # kept a little ahead of the device: a frame is only rendered once the
7
+ # queue has drained below `ahead` seconds, and the device only starts
8
+ # once the queue first reaches it.
9
+ #
10
+ # A sink answers #rate, #queue(samples), #queued_seconds, #start, #pause
11
+ # and #clear; whoever opened it closes it. An emulator running below
12
+ # real time lets the queue run dry and the audio stutters; `on_underrun`
13
+ # hears about the first time.
14
+ class Playback
15
+ AHEAD = 0.15
16
+
17
+ def initialize(sink, ahead: AHEAD, sleeper: ->(seconds) { sleep(seconds) }, on_underrun: nil)
18
+ @sink = sink
19
+ @ahead = ahead
20
+ @sleeper = sleeper
21
+ @on_underrun = on_underrun
22
+ @started = false
23
+ @stopped = false
24
+ @paused = false
25
+ @underrun = false
26
+ end
27
+
28
+ def stopped? = @stopped
29
+
30
+ def paused? = @paused
31
+
32
+ def underrun? = @underrun
33
+
34
+ # Stops at the end of the next wait; the queue is dropped rather than
35
+ # played out.
36
+ def stop = @stopped = true
37
+
38
+ # Holds the device where it is. Rendering stalls too, since the queue
39
+ # stops draining.
40
+ def pause
41
+ @paused = true
42
+ @sink.pause if @started
43
+ end
44
+
45
+ def resume
46
+ @paused = false
47
+ @sink.start if @started
48
+ end
49
+
50
+ # Plays to the end of the renderer and waits for the device to finish,
51
+ # yielding the seconds played so far after each frame. Returns
52
+ # :finished, :stopped, or :interrupted after a Ctrl-C.
53
+ def play(renderer)
54
+ @sink.pause
55
+ renderer.stream do |samples, rendered|
56
+ break if @stopped
57
+
58
+ enqueue(samples)
59
+ yield [rendered - @sink.queued_seconds, 0.0].max if block_given?
60
+ end
61
+ drain
62
+ @stopped ? :stopped : :finished
63
+ rescue Interrupt
64
+ @stopped = true
65
+ :interrupted
66
+ ensure
67
+ @sink.clear if @stopped
68
+ end
69
+
70
+ private
71
+
72
+ def enqueue(samples)
73
+ wait_below(@ahead)
74
+ underrun! if @started && @sink.queued_seconds.zero?
75
+ @sink.queue(samples)
76
+ start if @sink.queued_seconds >= @ahead
77
+ end
78
+
79
+ def drain
80
+ start
81
+ wait_below(1.fdiv(@sink.rate))
82
+ end
83
+
84
+ def start
85
+ return if @started
86
+
87
+ @started = true
88
+ @sink.start unless @paused
89
+ end
90
+
91
+ def underrun!
92
+ return if @underrun
93
+
94
+ @underrun = true
95
+ @on_underrun&.call
96
+ end
97
+
98
+ def wait_below(level)
99
+ until @stopped
100
+ excess = @sink.queued_seconds - level
101
+ break if excess.negative?
102
+
103
+ @sleeper.call([excess, 0.001].max)
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # Renders a .sid tune to a PCM file, or streams its samples to whoever
6
+ # plays them. A PSID tune with a play address runs on the bare rig;
7
+ # anything else drives its own interrupts and needs the whole machine.
8
+ #
9
+ # A .sid file carries no length, so the caller says how many seconds to
10
+ # render.
11
+ class Renderer
12
+ class UnknownFormatError < StandardError; end
13
+
14
+ DEFAULT_RATE = 44_100
15
+
16
+ CONTAINERS = { ".wav" => WAV, ".aiff" => AIFF, ".aif" => AIFF }.freeze
17
+
18
+ # The cycles the SID's filter integrates at a time; 1 renders the
19
+ # filter cycle by cycle, exactly.
20
+ attr_writer :filter_chunk
21
+
22
+ def initialize(tune, seconds:, song: nil, rate: DEFAULT_RATE, sid_model: tune.sid_model)
23
+ @tune = tune
24
+ @seconds = seconds
25
+ @song = song
26
+ @rate = rate
27
+ @sid_model = sid_model
28
+ @filter_chunk = SID::FILTER_CHUNK
29
+ end
30
+
31
+ def player
32
+ @player ||= (bare? ? BarePlayer : MachinePlayer).new(@tune, song: @song, sid_model: @sid_model)
33
+ end
34
+
35
+ # Yields the seconds rendered so far after every frame.
36
+ def render(path)
37
+ container = container_for(path)
38
+ player.start
39
+ container.open(path, rate: @rate) do |writer|
40
+ each_frame do |samples, seconds|
41
+ samples.each { |sample| writer << sample }
42
+ yield seconds if block_given?
43
+ end
44
+ end
45
+ end
46
+
47
+ # Starts the tune, then yields each frame's samples along with the
48
+ # seconds rendered so far.
49
+ def stream(&)
50
+ player.start
51
+ each_frame(&)
52
+ end
53
+
54
+ private
55
+
56
+ def bare? = @tune.format == "PSID" && @tune.play_address.positive?
57
+
58
+ def total_samples = (@seconds * @rate).round
59
+
60
+ # The cycles it takes to close exactly `total_samples` windows; the SID
61
+ # records floor(cycles * rate / clock) of them.
62
+ def total_cycles
63
+ ((total_samples * TimeOfDay::CLOCK_HZ) + @rate - 1) / @rate
64
+ end
65
+
66
+ def container_for(path)
67
+ extension = File.extname(path).downcase
68
+ CONTAINERS.fetch(extension) do
69
+ raise UnknownFormatError, "Unknown output format: #{extension}"
70
+ end
71
+ end
72
+
73
+ def each_frame
74
+ player.sid.record(rate: @rate, filter_chunk: @filter_chunk)
75
+ total = total_cycles
76
+ remaining = total
77
+ while remaining.positive?
78
+ samples = []
79
+ remaining -= player.frame(remaining) { |sample| samples << sample }
80
+ yield samples, (total - remaining).fdiv(TimeOfDay::CLOCK_HZ)
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # The host's audio device, fed through SDL's queue: mono signed 16-bit
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
+ 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
19
+
20
+ # int freq; Uint16 format; Uint8 channels, silence; Uint16 samples,
21
+ # padding; Uint32 size; then the callback and userdata pointers.
22
+ SPEC_LAYOUT = "lSCCSSLQQ"
23
+
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
+ attr_reader :rate
37
+
38
+ # With `exact_rate` false SDL may pick the device's own rate instead,
39
+ # which #rate then reports.
40
+ def initialize(rate:, exact_rate: false, buffer: 1024)
41
+ load_sdl
42
+ check(sdl(:SDL_InitSubSystem, INIT_AUDIO))
43
+ @device = open_device(rate, exact_rate ? 0 : ALLOW_FREQUENCY_CHANGE, buffer)
44
+ end
45
+
46
+ def queue(samples)
47
+ data = samples.pack("s*")
48
+ check(sdl(:SDL_QueueAudio, @device, data, data.bytesize))
49
+ end
50
+
51
+ def queued_seconds = sdl(:SDL_GetQueuedAudioSize, @device).fdiv(2 * rate)
52
+
53
+ def start = sdl(:SDL_PauseAudioDevice, @device, 0)
54
+
55
+ def pause = sdl(:SDL_PauseAudioDevice, @device, 1)
56
+
57
+ def clear = sdl(:SDL_ClearQueuedAudio, @device)
58
+
59
+ def close
60
+ return unless @device
61
+
62
+ sdl(:SDL_CloseAudioDevice, @device)
63
+ sdl(:SDL_QuitSubSystem, INIT_AUDIO)
64
+ @device = nil
65
+ end
66
+
67
+ private
68
+
69
+ def open_device(rate, allowed_changes, buffer)
70
+ wanted = [rate, AUDIO_S16SYS, 1, 0, buffer, 0, 0, 0, 0].pack(SPEC_LAYOUT)
71
+ obtained = "\0".b * 32
72
+ device = sdl(:SDL_OpenAudioDevice, nil, 0, wanted, obtained, allowed_changes)
73
+ raise Error, sdl(:SDL_GetError) if device.zero?
74
+
75
+ @rate = obtained.unpack1("l")
76
+ device
77
+ end
78
+
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
+ 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
94
+ end
95
+
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)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # RIFF/WAVE, little-endian throughout.
6
+ class WAV < PCMWriter
7
+ private
8
+
9
+ def pack_format = "s<*"
10
+
11
+ def header
12
+ ["RIFF", 0, "WAVE",
13
+ "fmt ", 16, 1, 1, rate, rate * 2, 2, 16,
14
+ "data", 0].pack("a4Va4a4VvvVVvva4V")
15
+ end
16
+
17
+ def patch
18
+ write_at(4, [36 + data_size].pack("V"))
19
+ write_at(40, [data_size].pack("V"))
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "badline/audio/options"
4
+ require "badline/audio/pcm_writer"
5
+ require "badline/audio/wav"
6
+ require "badline/audio/aiff"
7
+ require "badline/audio/bare_player"
8
+ require "badline/audio/machine_player"
9
+ require "badline/audio/renderer"
10
+ require "badline/audio/sdl_sink"
11
+ require "badline/audio/playback"
12
+ require "badline/audio/console"
13
+ require "badline/audio/jukebox"
14
+ require "badline/audio/cli"