badline 0.6.1 → 0.8.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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/CLAUDE.md +3 -1
  4. data/README.md +132 -247
  5. data/doc/library.md +62 -0
  6. data/doc/media.md +69 -0
  7. data/doc/sid-player.md +132 -0
  8. data/doc/snapshots.md +69 -0
  9. data/exe/badline-ruby +9 -1
  10. data/lib/badline/address_bus/extra_sids.rb +30 -0
  11. data/lib/badline/address_bus/sid_slots.rb +42 -0
  12. data/lib/badline/address_bus.rb +5 -0
  13. data/lib/badline/audio/aiff.rb +2 -2
  14. data/lib/badline/audio/bare_player.rb +23 -12
  15. data/lib/badline/audio/cli.rb +153 -58
  16. data/lib/badline/audio/jukebox.rb +121 -36
  17. data/lib/badline/audio/machine_player.rb +25 -10
  18. data/lib/badline/audio/pcm_writer.rb +10 -6
  19. data/lib/badline/audio/playback.rb +6 -5
  20. data/lib/badline/audio/queued_tune.rb +89 -0
  21. data/lib/badline/audio/renderer.rb +57 -10
  22. data/lib/badline/audio/stereo.rb +97 -0
  23. data/lib/badline/audio/terminal.rb +39 -6
  24. data/lib/badline/audio/wav.rb +1 -1
  25. data/lib/badline/audio.rb +2 -0
  26. data/lib/badline/cartridge/freezer.rb +2 -0
  27. data/lib/badline/cartridge.rb +3 -0
  28. data/lib/badline/frontend/app.rb +38 -35
  29. data/lib/badline/frontend/audio_sink.rb +6 -5
  30. data/lib/badline/frontend/boot.rb +9 -2
  31. data/lib/badline/frontend/buttons.rb +184 -0
  32. data/lib/badline/frontend/confirmation.rb +50 -0
  33. data/lib/badline/frontend/controls.rb +44 -32
  34. data/lib/badline/frontend/file_browser.rb +225 -0
  35. data/lib/badline/frontend/filter_view.rb +96 -0
  36. data/lib/badline/frontend/frame_report.rb +52 -0
  37. data/lib/badline/frontend/info_view.rb +120 -0
  38. data/lib/badline/frontend/menu_dialogs.rb +122 -0
  39. data/lib/badline/frontend/menu_media.rb +128 -0
  40. data/lib/badline/frontend/menu_pages.rb +198 -0
  41. data/lib/badline/frontend/painter.rb +185 -0
  42. data/lib/badline/frontend/pause_menu.rb +246 -0
  43. data/lib/badline/frontend/player_commands.rb +33 -0
  44. data/lib/badline/frontend/player_footer.rb +86 -0
  45. data/lib/badline/frontend/player_header.rb +60 -0
  46. data/lib/badline/frontend/player_screen.rb +124 -0
  47. data/lib/badline/frontend/player_state.rb +67 -0
  48. data/lib/badline/frontend/player_window.rb +228 -0
  49. data/lib/badline/frontend/scope.rb +42 -0
  50. data/lib/badline/frontend/sid_history.rb +175 -0
  51. data/lib/badline/frontend/sid_view.rb +140 -0
  52. data/lib/badline/frontend/snapshots.rb +1 -1
  53. data/lib/badline/frontend/sound.rb +0 -1
  54. data/lib/badline/frontend/timeline.rb +5 -1
  55. data/lib/badline/frontend/visualizer_view.rb +101 -0
  56. data/lib/badline/frontend.rb +21 -0
  57. data/lib/badline/media/disk_set.rb +108 -0
  58. data/lib/badline/media/queue.rb +158 -0
  59. data/lib/badline/media.rb +14 -8
  60. data/lib/badline/options/help.rb +34 -5
  61. data/lib/badline/options/option.rb +1 -1
  62. data/lib/badline/options/table.rb +5 -3
  63. data/lib/badline/options/validation.rb +54 -0
  64. data/lib/badline/options.rb +65 -46
  65. data/lib/badline/sdl.rb +16 -6
  66. data/lib/badline/sid/catch_up.rb +11 -1
  67. data/lib/badline/sid/filter.rb +9 -0
  68. data/lib/badline/sid/voice.rb +6 -0
  69. data/lib/badline/sid/waveform.rb +20 -5
  70. data/lib/badline/sid.rb +35 -3
  71. data/lib/badline/storage/hvsc.rb +41 -0
  72. data/lib/badline/storage/sid_file.rb +45 -25
  73. data/lib/badline/storage/song_lengths.rb +6 -29
  74. data/lib/badline/storage/stil/credit.rb +50 -0
  75. data/lib/badline/storage/stil.rb +126 -0
  76. data/lib/badline/storage.rb +8 -0
  77. data/lib/badline/version.rb +1 -1
  78. metadata +36 -1
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # A .sid tune in the player's Media::Queue, read only once the queue or
6
+ # the player first asks about it, so a queue of a whole collection
7
+ # starts at once. Its subtunes, SID model and lengths follow the options:
8
+ # --subtune picks the subtune to start on, --sid the model and --seconds the
9
+ # length, and without them the tune's own header and HVSC's database
10
+ # decide.
11
+ #
12
+ # A file that won't read as a tune, or lacks the subtune asked for, gets
13
+ # an #error and a single subtune.
14
+ class QueuedTune < Media::Queue::Entry
15
+ def initialize(path, options, subtune: nil)
16
+ super(path)
17
+ @options = options
18
+ @subtune = subtune
19
+ @tune = nil
20
+ @read = false
21
+ @error = ""
22
+ @lengths = nil
23
+ @lengths_read = false
24
+ end
25
+
26
+ def part
27
+ read
28
+ @part
29
+ end
30
+
31
+ def parts
32
+ read
33
+ @parts
34
+ end
35
+
36
+ # Why the tune can't play, or "" when it can.
37
+ def error
38
+ read
39
+ @error
40
+ end
41
+
42
+ def tune = @tune ||= Storage::SIDFile.new(path)
43
+
44
+ # Lets go of the tune's data, which #tune reads again if asked.
45
+ def release = @tune = nil
46
+
47
+ # Each SID's model, SID 1's first: the one --sid names for all of
48
+ # them, or each one's own.
49
+ def sid_models
50
+ forced = @options.sid_model
51
+ forced ? Array.new(tune.sids, forced) : tune.sid_models
52
+ end
53
+
54
+ def model_name = sid_models.map { |model| model.to_s.delete_prefix("mos") }.join(" + ")
55
+
56
+ def length(subtune) = @options.seconds || songlengths&.at(subtune - 1) || @options.fallback_seconds
57
+
58
+ # How long the subtune may stay silent before it ends, or nil when its
59
+ # length is known rather than the fallback.
60
+ def silence(subtune) = @options.seconds || songlengths&.at(subtune - 1) ? nil : @options.silence_seconds
61
+
62
+ # The header's name, author and release, those it fills in.
63
+ def header = [tune.name, tune.author, tune.released].reject(&:empty?)
64
+
65
+ private
66
+
67
+ def read
68
+ return if @read
69
+
70
+ @read = true
71
+ @parts = tune.subtunes
72
+ @part = @subtune || tune.start_subtune
73
+ @error = "no subtune #{@part}: the tune has #{@parts}" if @part > @parts
74
+ rescue Storage::SIDFile::FormatError => e
75
+ @error = e.message
76
+ end
77
+
78
+ # HVSC's database is keyed by the tune's MD5 and lists one length per
79
+ # subtune.
80
+ def songlengths
81
+ return @lengths if @lengths_read
82
+
83
+ @lengths_read = true
84
+ database = @options.songlengths || Storage::SongLengths.locate(path)
85
+ @lengths = database && Storage::SongLengths.new(database).lengths(tune.md5)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -3,40 +3,70 @@
3
3
  module Badline
4
4
  module Audio
5
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.
6
+ # plays them, in stereo with the left and right interleaved (Stereo).
7
+ # A PSID tune with a play address runs on the bare rig; anything else
8
+ # drives its own interrupts and needs the whole machine.
8
9
  #
9
10
  # A .sid file carries no length, so the caller says how many seconds to
10
- # render.
11
+ # render. For a tune whose length is a guess, #silence= also ends it
12
+ # once the output has held still for that many seconds.
11
13
  class Renderer
12
14
  class UnknownFormatError < StandardError; end
13
15
 
14
16
  DEFAULT_RATE = 44_100
15
17
 
18
+ # How far a sample may stray from where the output settled and still
19
+ # count as silence. A 6581 settles at a DC offset rather than at zero.
20
+ QUIET = 16
21
+
16
22
  CONTAINERS = { ".wav" => WAV, ".aiff" => AIFF, ".aif" => AIFF }.freeze
17
23
 
18
24
  # The cycles the SID's filter integrates at a time; 1 renders the
19
25
  # filter cycle by cycle, exactly.
20
26
  attr_writer :filter_chunk
21
27
 
22
- def initialize(tune, seconds:, song: nil, rate: DEFAULT_RATE, sid_model: tune.sid_model)
28
+ # The seconds rendered so far, short of the length once the tune has
29
+ # fallen silent.
30
+ attr_reader :rendered, :tune, :rate
31
+
32
+ # Hears each frame's samples and the seconds rendered by its end, as
33
+ # they are rendered.
34
+ attr_writer :observer
35
+
36
+ # Where #stream starts: the frames before it are rendered as fast as
37
+ # they go and yielded without their samples, so the seconds rendered
38
+ # still count up to it. Set while streaming, it skips ahead from the
39
+ # frame rendered last.
40
+ attr_writer :from
41
+
42
+ def initialize(tune, seconds:, subtune: nil, rate: DEFAULT_RATE, sid_models: tune.sid_models)
23
43
  @tune = tune
24
44
  @seconds = seconds
25
- @song = song
45
+ @subtune = subtune
26
46
  @rate = rate
27
- @sid_model = sid_model
47
+ @sid_models = sid_models
28
48
  @filter_chunk = SID::FILTER_CHUNK
49
+ @silent_samples = 0
50
+ @level = 0
51
+ @still = 0
52
+ @rendered = 0.0
53
+ @observer = ->(_samples, _rendered) {}
54
+ @from = 0.0
55
+ end
56
+
57
+ def silence=(seconds)
58
+ @silent_samples = (seconds * @rate).round
29
59
  end
30
60
 
31
61
  def player
32
- @player ||= (bare? ? BarePlayer : MachinePlayer).new(@tune, song: @song, sid_model: @sid_model)
62
+ @player ||= (bare? ? BarePlayer : MachinePlayer).new(@tune, subtune: @subtune, sid_models: @sid_models)
33
63
  end
34
64
 
35
65
  # Yields the seconds rendered so far after every frame.
36
66
  def render(path)
37
67
  container = container_for(path)
38
68
  player.start
39
- container.open(path, rate: @rate) do |writer|
69
+ container.open(path, rate: @rate, channels: 2) do |writer|
40
70
  each_frame do |samples, seconds|
41
71
  samples.each { |sample| writer << sample }
42
72
  yield seconds if block_given?
@@ -71,14 +101,31 @@ module Badline
71
101
  end
72
102
 
73
103
  def each_frame
74
- player.sid.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
104
+ player.stereo.record(rate: @rate, filter_chunk: @filter_chunk, clock_hz: player.clock_hz)
75
105
  total = total_cycles
76
106
  remaining = total
77
107
  while remaining.positive?
78
108
  samples = []
79
109
  remaining -= player.frame(remaining) { |sample| samples << sample }
80
- yield samples, (total - remaining).fdiv(player.clock_hz)
110
+ @rendered = (total - remaining).fdiv(player.clock_hz)
111
+ @observer.call(samples, @rendered)
112
+ yield(@rendered < @from ? [] : samples, @rendered)
113
+ break if fallen_silent?(samples)
114
+ end
115
+ end
116
+
117
+ # Counts the samples since the output last strayed more than QUIET
118
+ # from where it settled.
119
+ def fallen_silent?(samples)
120
+ return false if @silent_samples.zero? || samples.empty?
121
+
122
+ if samples.max - @level > QUIET || @level - samples.min > QUIET
123
+ @level = samples.last
124
+ @still = 0
125
+ else
126
+ @still += samples.length / 2
81
127
  end
128
+ @still >= @silent_samples
82
129
  end
83
130
  end
84
131
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Audio
5
+ # The SIDs a tune plays on, the bus's own first and the extra ones at
6
+ # the addresses the tune's header gives, each on its own model. Their
7
+ # output mixes to interleaved stereo: SID 1 on the left, SID 2 on the
8
+ # right and SID 3 in the centre, at half on each side. A tune on one SID
9
+ # plays the same on both channels.
10
+ class Stereo
11
+ # The SIDs, in the order the header numbers them.
12
+ attr_reader :sids
13
+
14
+ # Each SID's own samples, as the last #mix drained them.
15
+ attr_reader :outputs
16
+
17
+ def initialize(bus, tune, models)
18
+ @sids = [bus.sid]
19
+ @extras = []
20
+ tune.sid_addresses.each do |address|
21
+ sid = SID.new(model: models[@sids.size], at: address)
22
+ bus.add_sid(sid)
23
+ @sids << sid
24
+ @extras << sid
25
+ end
26
+ @fitted = models
27
+ @outputs = []
28
+ end
29
+
30
+ # The SIDs past the bus's own, which the player clocks itself.
31
+ attr_reader :extras
32
+
33
+ def synthesize! = @sids.each(&:synthesize!)
34
+
35
+ # The model each SID plays on.
36
+ def models = @sids.map(&:model)
37
+
38
+ # Puts every SID on `model`, or with :auto each back on the one it was
39
+ # fitted with.
40
+ def refit(model)
41
+ @sids.size.times do |index|
42
+ wanted = model == :auto ? @fitted[index] : model
43
+ sid = @sids[index]
44
+ sid.model = wanted unless sid.model == wanted
45
+ end
46
+ end
47
+
48
+ def record(rate:, filter_chunk:, clock_hz:)
49
+ @sids.each { |sid| sid.record(rate:, filter_chunk:, clock_hz:) }
50
+ end
51
+
52
+ # Drains every SID and yields the left and the right of each sample
53
+ # in turn.
54
+ def mix(&)
55
+ outputs = @outputs
56
+ outputs.clear
57
+ @sids.each { |sid| outputs << sid.drain_samples }
58
+ case outputs.size
59
+ when 1 then both(outputs[0], &)
60
+ when 2 then pair(outputs[0], outputs[1], &)
61
+ else centred(outputs[0], outputs[1], outputs[2], &)
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def both(samples)
68
+ i = 0
69
+ while i < samples.size
70
+ yield samples[i]
71
+ yield samples[i]
72
+ i += 1
73
+ end
74
+ end
75
+
76
+ def pair(left, right)
77
+ count = [left.size, right.size].min
78
+ i = 0
79
+ while i < count
80
+ yield left[i]
81
+ yield right[i]
82
+ i += 1
83
+ end
84
+ end
85
+
86
+ def centred(left, right, centre)
87
+ count = [left.size, right.size, centre.size].min
88
+ i = 0
89
+ while i < count
90
+ yield ((2 * left[i]) + centre[i]) / 3
91
+ yield ((2 * right[i]) + centre[i]) / 3
92
+ i += 1
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -10,9 +10,13 @@ module Badline
10
10
  # stty and poll(2) for the native badline.
11
11
  class Terminal
12
12
  KEYS = {
13
- "n" => :next, "\e[C" => :next,
14
- "p" => :previous, "\e[D" => :previous,
13
+ "n" => :next, "p" => :previous,
14
+ "\e[C" => :next_subtune, "\e[D" => :previous_subtune,
15
15
  " " => :pause,
16
+ "s" => :shuffle,
17
+ "l" => :loop,
18
+ "a" => :all_subtunes,
19
+ "," => :back, "." => :forward,
16
20
  "q" => :quit
17
21
  }.freeze
18
22
 
@@ -20,6 +24,8 @@ module Badline
20
24
  @input = input
21
25
  @output = output
22
26
  @closed = false
27
+ @tune = 1
28
+ @tunes = 1
23
29
  end
24
30
 
25
31
  # Raw input and a hidden cursor for the length of the block.
@@ -46,12 +52,39 @@ module Badline
46
52
 
47
53
  def header(lines)
48
54
  lines.each { |line| @output.print "#{line}\r\n" }
49
- @output.print "n/→ next p/← previous space pause q quit\r\n"
55
+ @output.print "←/→ subtune n/p tune ,/. seek space pause s shuffle l loop a all subtunes q quit\r\n"
50
56
  end
51
57
 
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))
58
+ # Prints lines above the status line, such as the next tune's header.
59
+ def announce(lines)
60
+ lines.each { |line| @output.print "\r\e[K#{line}\r\n" }
61
+ end
62
+
63
+ # The tune's place in the queue, which the status line shows once
64
+ # there is more than one.
65
+ def place(tune, tunes)
66
+ @tune = tune
67
+ @tunes = tunes
68
+ end
69
+
70
+ # Where :seek asks the subtune to play from, in seconds. Only a window
71
+ # asks for it, by a click on the time the subtune has played.
72
+ def seek_to = 0.0
73
+
74
+ # The files dropped on a window since it last said :drop.
75
+ def dropped = []
76
+
77
+ # Hears of the STIL fields for the tune and for the subtune starting,
78
+ # which the terminal prints as the CLI announces them.
79
+ def stil(fields, subtune, subtune_fields) = [fields, subtune, subtune_fields]
80
+
81
+ # Hears of each subtune's renderer as it starts playing.
82
+ def playing(renderer) = renderer
83
+
84
+ def status(subtune:, subtunes:, elapsed:, length:, notes: [])
85
+ line = format("subtune %<subtune>d/%<subtunes>d %<elapsed>s / %<length>s",
86
+ subtune:, subtunes:, elapsed: clock(elapsed), length: clock(length))
87
+ line = format("tune %<tune>d/%<tunes>d %<line>s", tune: @tune, tunes: @tunes, line:) if @tunes > 1
55
88
  @output.print "\r\e[K#{([line] + notes).join(' ')}"
56
89
  @output.flush
57
90
  end
@@ -10,7 +10,7 @@ module Badline
10
10
 
11
11
  def header
12
12
  ["RIFF", 0, "WAVE",
13
- "fmt ", 16, 1, 1, rate, rate * 2, 2, 16,
13
+ "fmt ", 16, 1, channels, rate, rate * channels * 2, channels * 2, 16,
14
14
  "data", 0].pack("a4Va4a4VvvVVvva4V")
15
15
  end
16
16
 
data/lib/badline/audio.rb CHANGED
@@ -3,10 +3,12 @@
3
3
  require "badline/audio/pcm_writer"
4
4
  require "badline/audio/wav"
5
5
  require "badline/audio/aiff"
6
+ require "badline/audio/stereo"
6
7
  require "badline/audio/bare_player"
7
8
  require "badline/audio/machine_player"
8
9
  require "badline/audio/renderer"
9
10
  require "badline/audio/playback"
10
11
  require "badline/audio/terminal"
11
12
  require "badline/audio/jukebox"
13
+ require "badline/audio/queued_tune"
12
14
  require "badline/audio/cli"
@@ -8,6 +8,8 @@ module Badline
8
8
  # cartridge ROM in Ultimax mode. The cartridge lets go of NMI when its
9
9
  # software acknowledges the freeze.
10
10
  module Freezer
11
+ def freezer? = true
12
+
11
13
  def press_button
12
14
  @button = true
13
15
  self.nmi = true if freeze_allowed?
@@ -179,6 +179,9 @@ module Badline
179
179
  # The RES line on the expansion port.
180
180
  def reset; end
181
181
 
182
+ # Whether the cartridge has a freeze button.
183
+ def freezer? = false
184
+
182
185
  # The freeze button, which cartridges without one ignore.
183
186
  def press_button; end
184
187
 
@@ -7,8 +7,8 @@ module Badline
7
7
  # changed lines, present and wait. Pacer decides the cycles and the wait.
8
8
  class App
9
9
  SCALE = 2
10
+ DROPFILE = 0x1000
10
11
  TITLE = "Badline"
11
- STAGES = %w[events emulate audio blit present wait].freeze
12
12
 
13
13
  # Takes the frame limit, the pacing, the snapshot path, the sound and
14
14
  # the verbosity from Options, and runs the timeline's events.
@@ -22,11 +22,11 @@ module Badline
22
22
  @screen = Screen.new(computer.vic)
23
23
  @led = DriveLed.for(computer)
24
24
  @controls = Controls.new(computer)
25
- @spent = Array.new(STAGES.size, 0.0)
26
- @slowest = 0.0
27
25
  open_window
28
26
  @sound = Sound.new(computer.sid, options.sound?, @verbose)
29
27
  @gamepads = Gamepads.new(computer, @verbose)
28
+ @frame_report = FrameReport.new(@sound)
29
+ @menu = PauseMenu.new(Painter.new(@renderer), options)
30
30
  end
31
31
 
32
32
  def run
@@ -34,8 +34,7 @@ module Badline
34
34
  @running = true
35
35
  @started = @reported = now
36
36
  @pacer.start(@started)
37
- @reported_samples = 0
38
- frame while @running
37
+ @menu.open? ? paused_frame : frame while @running
39
38
  @snapshots.finish
40
39
  @computer.drive1541&.flush
41
40
  @gamepads.close
@@ -64,9 +63,7 @@ module Badline
64
63
  end
65
64
 
66
65
  def finish_frame(stamps)
67
- STAGES.size.times { |stage| @spent[stage] += stamps[stage + 1] - stamps[stage] }
68
- took = stamps[-2] - stamps.first
69
- @slowest = took if took > @slowest
66
+ @frame_report.add(stamps) if @verbose
70
67
  @frames += 1
71
68
  @timeline.run(@computer, @frames)
72
69
  @running = false if @frames == @frame_limit || @timeline.quit?(@frames)
@@ -95,8 +92,6 @@ module Badline
95
92
  @texture = SDL.SDL_CreateTexture(
96
93
  @renderer, SDL::PIXELFORMAT_RGB888, SDL::TEXTUREACCESS_STREAMING, Screen::WIDTH, Screen::HEIGHT
97
94
  )
98
- SDL.rect_w(SDL.rect, Screen::WIDTH)
99
- SDL.rect_h(SDL.rect, Screen::HEIGHT)
100
95
  end
101
96
 
102
97
  def close_window
@@ -107,7 +102,7 @@ module Badline
107
102
  end
108
103
 
109
104
  def handle_events
110
- handle_event(SDL.event_type(SDL.event)) while SDL.SDL_PollEvent(SDL.event) != 0
105
+ handle_event(SDL.event_type(SDL.event)) while !@menu.open? && SDL.SDL_PollEvent(SDL.event) != 0
111
106
  end
112
107
 
113
108
  def handle_event(type)
@@ -122,6 +117,7 @@ module Badline
122
117
  @controls.mouse_button(SDL.event_button(SDL.event), type == SDL::MOUSEBUTTONDOWN)
123
118
  when SDL::CONTROLLERDEVICEADDED, SDL::CONTROLLERDEVICEREMOVED
124
119
  @gamepads.rescan
120
+ when DROPFILE then resume(false) unless @menu.drop(@computer, @controls, @sound)
125
121
  end
126
122
  end
127
123
 
@@ -145,20 +141,45 @@ module Badline
145
141
  @controls.computer = computer
146
142
  @gamepads.computer = computer
147
143
  @sound.sid = computer.sid
144
+ @snapshots.computer = computer
148
145
  end
149
146
 
150
147
  def handle_toggle(scancode)
151
148
  if scancode == Keys::TAB
152
- @controls.cycle_mode(SDL.event_mod(SDL.event).anybits?(SDL::KMOD_SHIFT) ? -1 : 1)
153
- SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
149
+ @controls.toggle_keys
154
150
  elsif scancode == Keys::F9
155
- @controls.swap_ports
151
+ return open_menu
156
152
  else
157
153
  @sound.toggle_mute
158
154
  end
159
155
  update_title
160
156
  end
161
157
 
158
+ def open_menu = @menu.show(@computer, @controls, @sound)
159
+
160
+ # Closes the menu and runs the machine on from where it stood,
161
+ # pressing the cartridge's freeze button if asked.
162
+ def resume(freeze)
163
+ @menu.close
164
+ SDL.SDL_SetRelativeMouseMode(@controls.pot_device? ? 1 : 0)
165
+ @timeline.press_freeze(@computer, @frames) if freeze
166
+ @reported = now
167
+ @pacer.start(@reported)
168
+ update_title
169
+ end
170
+
171
+ # Runs a frame of the menu, which has the keys and the mouse while the
172
+ # machine stands still.
173
+ def paused_frame
174
+ action = @menu.frame(@renderer, @texture, @controls)
175
+ if action == :quit
176
+ @running = false
177
+ elsif !action.nil?
178
+ swap(@menu.computer) if action == :swap
179
+ resume(action == :freeze)
180
+ end
181
+ end
182
+
162
183
  def update_title
163
184
  title = TITLE
164
185
  title += " [#{@controls.tag}]" unless @controls.tag.empty?
@@ -183,12 +204,12 @@ module Badline
183
204
 
184
205
  def upload
185
206
  @screen.update
186
- SDL.SDL_UpdateTexture(@texture, SDL.rect, @screen.pixels, Screen::ROW_BYTES)
207
+ SDL.SDL_UpdateTexture(@texture, nil, @screen.pixels, Screen::ROW_BYTES)
187
208
  end
188
209
 
189
210
  def draw
190
211
  SDL.SDL_RenderClear(@renderer)
191
- SDL.SDL_RenderCopy(@renderer, @texture, SDL.rect, SDL.rect)
212
+ SDL.SDL_RenderCopy(@renderer, @texture, nil, nil)
192
213
  @led&.draw(@renderer)
193
214
  @timeline.screenshots(@frames + 1).each { |path| Screenshot.write(@renderer, path) }
194
215
  SDL.SDL_RenderPresent(@renderer)
@@ -197,28 +218,10 @@ module Badline
197
218
  def report(at)
198
219
  @pacer.check(50, at - @reported, at)
199
220
  @pacer.measure(50, at - @reported)
200
- report_frames(at) if @verbose
201
- @spent = Array.new(STAGES.size, 0.0)
202
- @slowest = 0.0
221
+ @frame_report.show(at - @reported) if @verbose
203
222
  @reported = at
204
223
  end
205
224
 
206
- def report_frames(at)
207
- fps = 50 / (at - @reported)
208
- stages = STAGES.each_with_index.map { |name, stage| "#{name} #{(@spent[stage] * 20).round(2)}" }
209
- puts "#{fps.round(1)} fps, per frame ms: #{stages.join(' ')}, slowest work #{(@slowest * 1000).round(2)}"
210
- report_sound(at) if @sound.on?
211
- end
212
-
213
- def report_sound(at)
214
- sound = @sound
215
- rate = (sound.queued - @reported_samples) / (at - @reported)
216
- queue = sound.high.zero? ? "empty" : "#{(sound.low * 1000).round(1)}-#{(sound.high * 1000).round(1)} ms"
217
- puts " sound #{rate.round} samples/s, queue #{queue}, #{sound.underruns} underruns, #{sound.dropped} dropped"
218
- sound.reset_levels
219
- @reported_samples = sound.queued
220
- end
221
-
222
225
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
223
226
  end
224
227
  end
@@ -2,9 +2,10 @@
2
2
 
3
3
  module Badline
4
4
  module Frontend
5
- # The host's audio device for --headless, fed through SDL's queue: mono
6
- # signed 16-bit samples go in at the device's rate and SDL plays them
7
- # out behind us. It answers what Audio::Playback asks of a sink.
5
+ # The host's audio device for --headless and the SID player, fed through
6
+ # SDL's queue: stereo signed 16-bit samples, the left and right
7
+ # interleaved, go in at the device's rate and SDL plays them out behind
8
+ # us. It answers what Audio::Playback asks of a sink.
8
9
  class AudioSink
9
10
  class Error < Audio::Playback::DeviceError; end
10
11
 
@@ -33,7 +34,7 @@ module Badline
33
34
  check(SDL.SDL_QueueAudio(@device, buffer, bytes))
34
35
  end
35
36
 
36
- def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (2.0 * @rate)
37
+ def queued_seconds = SDL.SDL_GetQueuedAudioSize(@device) / (4.0 * @rate)
37
38
 
38
39
  def start = SDL.SDL_PauseAudioDevice(@device, 0)
39
40
 
@@ -54,7 +55,7 @@ module Badline
54
55
  def open_device(rate, allowed_changes, buffer)
55
56
  SDL.spec_freq(SDL.wanted, rate)
56
57
  SDL.spec_format(SDL.wanted, SDL::AUDIO_S16LSB)
57
- SDL.spec_channels(SDL.wanted, 1)
58
+ SDL.spec_channels(SDL.wanted, 2)
58
59
  SDL.spec_samples(SDL.wanted, buffer)
59
60
  @device = SDL.SDL_OpenAudioDevice(nil, 0, SDL.wanted, SDL.obtained, allowed_changes)
60
61
  raise Error, SDL.SDL_GetError if @device.zero?
@@ -39,12 +39,19 @@ module Badline
39
39
  return computer
40
40
  end
41
41
 
42
+ start(options, media, options.writable?)
43
+ end
44
+
45
+ # A new machine built from the options, with `media`, if not nil,
46
+ # attached and started as the command line does, its disk writable if
47
+ # `writable`.
48
+ def self.start(options, media, writable)
42
49
  computer = Computer.new(sid_model: options.sid_model || Media.sid_model(media), reu: options.reu,
43
50
  region: options.ntsc? ? Region::NTSC : Region::PAL)
44
51
  Media::TrueDrive.plug(computer) if options.true_drive?
45
52
  unless media.nil?
46
- puts Media.attach(computer, media, autostart: options.autostart?, song: options.song,
47
- disk: { read_only: options.read_only? })
53
+ puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
54
+ disk: { read_only: !writable })
48
55
  end
49
56
  computer
50
57
  end