badline 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +167 -0
- data/CLAUDE.md +217 -0
- data/CODE_OF_CONDUCT.md +92 -0
- data/CONTRIBUTING.md +51 -0
- data/README.md +224 -31
- data/doc/pinned-behaviour.md +1117 -0
- data/exe/badline +22 -2
- data/exe/badline-sid +34 -0
- data/lib/badline/address_bus.rb +88 -23
- data/lib/badline/audio/aiff.rb +36 -0
- data/lib/badline/audio/bare_player.rb +129 -0
- data/lib/badline/audio/cli.rb +133 -0
- data/lib/badline/audio/console.rb +75 -0
- data/lib/badline/audio/jukebox.rb +82 -0
- data/lib/badline/audio/machine_player.rb +57 -0
- data/lib/badline/audio/options.rb +137 -0
- data/lib/badline/audio/pcm_writer.rb +57 -0
- data/lib/badline/audio/playback.rb +108 -0
- data/lib/badline/audio/renderer.rb +85 -0
- data/lib/badline/audio/sdl_sink.rb +102 -0
- data/lib/badline/audio/wav.rb +23 -0
- data/lib/badline/audio.rb +14 -0
- data/lib/badline/cartridge/action_replay.rb +111 -0
- data/lib/badline/cartridge/atomic_power.rb +24 -0
- data/lib/badline/cartridge/bank.rb +81 -0
- data/lib/badline/cartridge/comal80.rb +48 -0
- data/lib/badline/cartridge/dinamic.rb +34 -0
- data/lib/badline/cartridge/easy_flash.rb +122 -0
- data/lib/badline/cartridge/epyx_fastload.rb +71 -0
- data/lib/badline/cartridge/final_cartridge3.rb +77 -0
- data/lib/badline/cartridge/flash.rb +265 -0
- data/lib/badline/cartridge/freezer.rb +31 -0
- data/lib/badline/cartridge/fun_play.rb +38 -0
- data/lib/badline/cartridge/g_mod2.rb +62 -0
- data/lib/badline/cartridge/game_system.rb +40 -0
- data/lib/badline/cartridge/kcs_power.rb +72 -0
- data/lib/badline/cartridge/mach5.rb +34 -0
- data/lib/badline/cartridge/magic_desk.rb +6 -3
- data/lib/badline/cartridge/ocean.rb +11 -12
- data/lib/badline/cartridge/pagefox.rb +56 -0
- data/lib/badline/cartridge/retro_replay.rb +276 -0
- data/lib/badline/cartridge/rex_utility.rb +33 -0
- data/lib/badline/cartridge/rgcd.rb +48 -0
- data/lib/badline/cartridge/simons_basic.rb +40 -0
- data/lib/badline/cartridge/standard.rb +3 -19
- data/lib/badline/cartridge/super_games.rb +36 -0
- data/lib/badline/cartridge/westermann.rb +34 -0
- data/lib/badline/cartridge/zaxxon.rb +50 -0
- data/lib/badline/cartridge.rb +131 -15
- data/lib/badline/cia/interrupt_register.rb +88 -0
- data/lib/badline/cia/serial.rb +219 -0
- data/lib/badline/cia/timer.rb +43 -31
- data/lib/badline/cia.rb +109 -74
- data/lib/badline/color_memory.rb +14 -5
- data/lib/badline/computer.rb +68 -8
- data/lib/badline/control_ports.rb +54 -9
- data/lib/badline/cpu/addressing.rb +131 -0
- data/lib/badline/cpu/microcode.rb +171 -0
- data/lib/badline/cpu/operations.rb +88 -0
- data/lib/badline/cpu/stack_operations.rb +82 -0
- data/lib/badline/cpu.rb +108 -167
- data/lib/badline/cycleable.rb +3 -0
- data/lib/badline/datasette.rb +81 -0
- data/lib/badline/debug_register.rb +28 -0
- data/lib/badline/gui/application.rb +79 -18
- data/lib/badline/gui/gamepads.rb +97 -0
- data/lib/badline/gui/joy_map.rb +15 -7
- data/lib/badline/gui.rb +1 -0
- data/lib/badline/input/mouse1351.rb +41 -0
- data/lib/badline/input/paddles.rb +36 -0
- data/lib/badline/input.rb +4 -0
- data/lib/badline/instruction_set/arithmetic.rb +16 -21
- data/lib/badline/instruction_set/bitwise.rb +17 -22
- data/lib/badline/instruction_set/branch.rb +20 -19
- data/lib/badline/instruction_set/flag.rb +7 -7
- data/lib/badline/instruction_set/illegal.rb +14 -22
- data/lib/badline/instruction_set/inc_dec.rb +8 -10
- data/lib/badline/instruction_set/stack.rb +5 -57
- data/lib/badline/instruction_set/transfer.rb +9 -9
- data/lib/badline/instruction_set.rb +9 -31
- data/lib/badline/interrupts.rb +104 -0
- data/lib/badline/joystick.rb +9 -9
- data/lib/badline/kernal_trap/channel.rb +33 -0
- data/lib/badline/kernal_trap/drive.rb +239 -0
- data/lib/badline/kernal_trap/file.rb +4 -24
- data/lib/badline/kernal_trap/load.rb +2 -2
- data/lib/badline/kernal_trap/routine.rb +34 -0
- data/lib/badline/kernal_trap/save.rb +1 -1
- data/lib/badline/kernal_trap/serial.rb +139 -0
- data/lib/badline/kernal_trap.rb +4 -0
- data/lib/badline/keyboard.rb +40 -16
- data/lib/badline/media.rb +48 -10
- data/lib/badline/roms/eapi/LICENSE.md +15 -0
- data/lib/badline/roms/eapi/README +41 -0
- data/lib/badline/roms/eapi/eapi-am29f040-14 +0 -0
- data/lib/badline/sid/decimator.rb +46 -0
- data/lib/badline/sid/envelope.rb +232 -0
- data/lib/badline/sid/filter.rb +213 -0
- data/lib/badline/sid/voice.rb +61 -0
- data/lib/badline/sid/waveform/combined.rb +126 -0
- data/lib/badline/sid/waveform/fast_forward.rb +105 -0
- data/lib/badline/sid/waveform/noise_writeback.rb +82 -0
- data/lib/badline/sid/waveform.rb +289 -0
- data/lib/badline/sid.rb +274 -7
- data/lib/badline/status.rb +32 -27
- data/lib/badline/storage/crt_file.rb +18 -2
- data/lib/badline/storage/disk_image.rb +52 -5
- data/lib/badline/storage/host_directory.rb +18 -4
- data/lib/badline/storage/sid_file.rb +274 -0
- data/lib/badline/storage/song_lengths.rb +65 -0
- data/lib/badline/storage/t64.rb +67 -0
- data/lib/badline/storage/tap.rb +68 -0
- data/lib/badline/storage.rb +19 -0
- data/lib/badline/time_of_day.rb +105 -52
- data/lib/badline/version.rb +1 -1
- data/lib/badline/vic/bank.rb +14 -6
- data/lib/badline/vic/border_mask.rb +76 -0
- data/lib/badline/vic/collisions.rb +104 -0
- data/lib/badline/vic/color_patches.rb +33 -0
- data/lib/badline/vic/display_state.rb +92 -29
- data/lib/badline/vic/graphics_mode.rb +78 -53
- data/lib/badline/vic/graphics_shifter.rb +140 -0
- data/lib/badline/vic/register_log.rb +63 -0
- data/lib/badline/vic/registers.rb +3 -0
- data/lib/badline/vic/sequencer.rb +163 -137
- data/lib/badline/vic/sequencer_output.rb +116 -0
- data/lib/badline/vic/sprite/internal_bus.rb +49 -0
- data/lib/badline/vic/sprite/shifter.rb +129 -0
- data/lib/badline/vic/sprite.rb +275 -65
- data/lib/badline/vic/sprites.rb +220 -58
- data/lib/badline/vic.rb +373 -49
- data/lib/badline.rb +5 -0
- data/vendor/.gitkeep +0 -0
- metadata +103 -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"
|