badline 0.6.1 → 0.7.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +125 -46
  4. data/exe/badline-ruby +9 -1
  5. data/lib/badline/address_bus/extra_sids.rb +30 -0
  6. data/lib/badline/address_bus/sid_slots.rb +42 -0
  7. data/lib/badline/address_bus.rb +5 -0
  8. data/lib/badline/audio/aiff.rb +2 -2
  9. data/lib/badline/audio/bare_player.rb +23 -12
  10. data/lib/badline/audio/cli.rb +153 -58
  11. data/lib/badline/audio/jukebox.rb +121 -36
  12. data/lib/badline/audio/machine_player.rb +25 -10
  13. data/lib/badline/audio/pcm_writer.rb +10 -6
  14. data/lib/badline/audio/playback.rb +6 -5
  15. data/lib/badline/audio/queued_tune.rb +89 -0
  16. data/lib/badline/audio/renderer.rb +57 -10
  17. data/lib/badline/audio/stereo.rb +97 -0
  18. data/lib/badline/audio/terminal.rb +39 -6
  19. data/lib/badline/audio/wav.rb +1 -1
  20. data/lib/badline/audio.rb +2 -0
  21. data/lib/badline/frontend/audio_sink.rb +6 -5
  22. data/lib/badline/frontend/boot.rb +1 -1
  23. data/lib/badline/frontend/buttons.rb +90 -0
  24. data/lib/badline/frontend/filter_view.rb +96 -0
  25. data/lib/badline/frontend/info_view.rb +120 -0
  26. data/lib/badline/frontend/painter.rb +175 -0
  27. data/lib/badline/frontend/player_commands.rb +33 -0
  28. data/lib/badline/frontend/player_footer.rb +86 -0
  29. data/lib/badline/frontend/player_header.rb +60 -0
  30. data/lib/badline/frontend/player_screen.rb +124 -0
  31. data/lib/badline/frontend/player_state.rb +67 -0
  32. data/lib/badline/frontend/player_window.rb +228 -0
  33. data/lib/badline/frontend/scope.rb +42 -0
  34. data/lib/badline/frontend/sid_history.rb +175 -0
  35. data/lib/badline/frontend/sid_view.rb +140 -0
  36. data/lib/badline/frontend/visualizer_view.rb +101 -0
  37. data/lib/badline/frontend.rb +14 -0
  38. data/lib/badline/media/queue.rb +158 -0
  39. data/lib/badline/media.rb +13 -8
  40. data/lib/badline/options/help.rb +34 -5
  41. data/lib/badline/options/option.rb +1 -1
  42. data/lib/badline/options/table.rb +4 -2
  43. data/lib/badline/options/validation.rb +54 -0
  44. data/lib/badline/options.rb +62 -43
  45. data/lib/badline/sdl.rb +16 -6
  46. data/lib/badline/sid/catch_up.rb +11 -1
  47. data/lib/badline/sid/filter.rb +9 -0
  48. data/lib/badline/sid/voice.rb +6 -0
  49. data/lib/badline/sid/waveform.rb +20 -5
  50. data/lib/badline/sid.rb +35 -3
  51. data/lib/badline/storage/hvsc.rb +41 -0
  52. data/lib/badline/storage/sid_file.rb +45 -25
  53. data/lib/badline/storage/song_lengths.rb +6 -29
  54. data/lib/badline/storage/stil/credit.rb +50 -0
  55. data/lib/badline/storage/stil.rb +126 -0
  56. data/lib/badline/storage.rb +8 -0
  57. data/lib/badline/version.rb +1 -1
  58. metadata +24 -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"
@@ -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?
@@ -43,7 +43,7 @@ module Badline
43
43
  region: options.ntsc? ? Region::NTSC : Region::PAL)
44
44
  Media::TrueDrive.plug(computer) if options.true_drive?
45
45
  unless media.nil?
46
- puts Media.attach(computer, media, autostart: options.autostart?, song: options.song,
46
+ puts Media.attach(computer, media, autostart: options.autostart?, subtune: options.subtune,
47
47
  disk: { read_only: options.read_only? })
48
48
  end
49
49
  computer
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # The SID player's buttons: each is drawn where it goes on every frame,
6
+ # and remembers its place, so a click finds the action under it. The
7
+ # one under the pointer lights up, and one that's on is drawn reversed.
8
+ class Buttons
9
+ PAD = 2
10
+ HEIGHT = Painter::GLYPH + (PAD * 2)
11
+
12
+ def initialize(painter)
13
+ @painter = painter
14
+ @lefts = []
15
+ @tops = []
16
+ @widths = []
17
+ @heights = []
18
+ @actions = []
19
+ @pointer_x = -1
20
+ @pointer_y = -1
21
+ end
22
+
23
+ def forget
24
+ @lefts.clear
25
+ @tops.clear
26
+ @widths.clear
27
+ @heights.clear
28
+ @actions.clear
29
+ end
30
+
31
+ def point(left, top)
32
+ @pointer_x = left
33
+ @pointer_y = top
34
+ end
35
+
36
+ # Draws a button labelled `label` with its top left at `left`, `top`,
37
+ # and returns its width.
38
+ def text(left, top, label, action, on: false)
39
+ width = Painter.width(label) + (PAD * 2)
40
+ place(left, top, width, HEIGHT, action)
41
+ @painter.box(left, top, width, HEIGHT, SIDView::TEXT) if on
42
+ @painter.text(left + PAD, top + PAD, label, on ? PlayerScreen::BACKGROUND : color(@actions.size - 1))
43
+ width
44
+ end
45
+
46
+ # Draws an icon button `scale` times the size of a glyph, and returns
47
+ # its width.
48
+ def icon(left, top, name, action, scale: 1)
49
+ size = (Painter::GLYPH * scale) + (PAD * 2)
50
+ place(left, top, size, size, action)
51
+ @painter.icon(name, left + PAD, top + PAD, color(@actions.size - 1), scale:)
52
+ size
53
+ end
54
+
55
+ # Makes a box drawn elsewhere clickable, such as the bar showing the
56
+ # time played.
57
+ def area(box, action)
58
+ place(box[0], box[1], box[2], box[3], action)
59
+ end
60
+
61
+ # The action of the button at `left`, `top`, or nil.
62
+ def action_at(left, top)
63
+ index = 0
64
+ while index < @actions.size
65
+ return @actions[index] if inside?(index, left, top)
66
+
67
+ index += 1
68
+ end
69
+ nil
70
+ end
71
+
72
+ private
73
+
74
+ def place(left, top, width, height, action)
75
+ @lefts << left
76
+ @tops << top
77
+ @widths << width
78
+ @heights << height
79
+ @actions << action
80
+ end
81
+
82
+ def color(index) = inside?(index, @pointer_x, @pointer_y) ? SIDView::BRIGHT : SIDView::TEXT
83
+
84
+ def inside?(index, left, top)
85
+ left >= @lefts[index] && left < @lefts[index] + @widths[index] &&
86
+ top >= @tops[index] && top < @tops[index] + @heights[index]
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Badline
4
+ module Frontend
5
+ # Draws the SID's filter as the player shows it: its modes, cutoff,
6
+ # resonance and volume, and its response across the audible range on
7
+ # the chip playing, whose cutoff curve the two models don't share.
8
+ class FilterView
9
+ TEXT = SIDView::TEXT
10
+ BRIGHT = SIDView::BRIGHT
11
+ DIM = SIDView::DIM
12
+ BOX = SIDView::BOX
13
+
14
+ MODES = ["LP", "BP", "HP", "3 OFF"].freeze
15
+
16
+ LEFT = SIDView::LEFT
17
+ CURVE_LEFT = 224
18
+ WIDTH = SIDView::WAVE_WIDTH
19
+ HEIGHT = SIDView::PANEL_HEIGHT
20
+
21
+ # The lowest and highest frequencies on the curve's axis.
22
+ LOW_HZ = 30.0
23
+ HIGH_HZ = 16_000.0
24
+
25
+ def initialize(painter)
26
+ @painter = painter
27
+ @curve = Array.new(WIDTH + 1, 0)
28
+ end
29
+
30
+ def draw(history, frame, top, model)
31
+ cutoff = (history.register(frame, 0x15) & 0x07) | (history.register(frame, 0x16) << 3)
32
+ resonance = history.register(frame, 0x17) >> 4
33
+ mode_volume = history.register(frame, 0x18)
34
+ hertz = SID::Filter::W0.fetch(model)[cutoff] / (2 * Math::PI * SID::Filter::SCALE)
35
+ paint = @painter
36
+ paint.text(LEFT, top, "FILTER", BRIGHT)
37
+ x = LEFT + 64
38
+ MODES.each_with_index do |name, bit|
39
+ x += paint.text(x, top, name, mode_volume[bit + 4] == 1 ? BRIGHT : DIM) + 8
40
+ end
41
+ paint.text(LEFT, top + 16, format("CUTOFF $%<cutoff>03X", cutoff:), TEXT)
42
+ paint.text(LEFT, top + 28, format(" %<hertz>d HZ", hertz: hertz.round), TEXT)
43
+ paint.text(LEFT, top + 40, format("RES %<res>X VOL %<vol>X", res: resonance, vol: mode_volume & 0x0f), TEXT)
44
+ draw_curve(hertz, resonance, mode_volume, top)
45
+ end
46
+
47
+ private
48
+
49
+ # The gain for the modes selected on a logarithmic axis, with the
50
+ # cutoff marked.
51
+ def draw_curve(cutoff_hertz, resonance, mode_volume, top)
52
+ paint = @painter
53
+ paint.box(CURVE_LEFT, top, WIDTH, HEIGHT, BOX)
54
+ return paint.text(CURVE_LEFT + 8, top + 8, "NO MODE", DIM) if mode_volume.nobits?(0x70)
55
+
56
+ quality = 0.707 + (resonance / 15.0)
57
+ cutoff = [cutoff_hertz, 1.0].max
58
+ points = @curve
59
+ x = 0
60
+ while x <= WIDTH
61
+ hertz = LOW_HZ * ((HIGH_HZ / LOW_HZ)**(x.to_f / WIDTH))
62
+ decibels = gain(hertz / cutoff, quality, mode_volume).clamp(-36.0, 12.0)
63
+ points[x] = Painter.point(CURVE_LEFT + x, top + 8 + (((12.0 - decibels) * (HEIGHT - 12)) / 48.0).round)
64
+ x += 1
65
+ end
66
+ paint.polyline(points, BRIGHT)
67
+ marker = (WIDTH * Math.log(cutoff / LOW_HZ) / Math.log(HIGH_HZ / LOW_HZ)).round
68
+ paint.box(CURVE_LEFT + marker, top, 1, HEIGHT, DIM) if marker.between?(0, WIDTH)
69
+ end
70
+
71
+ # The gain in decibels of a two-pole state-variable filter at `ratio`
72
+ # of its cutoff, summing the low-, band- and high-pass outputs chosen.
73
+ def gain(ratio, quality, mode_volume)
74
+ real = 1.0 - (ratio * ratio)
75
+ imaginary = ratio / quality
76
+ denominator = (real * real) + (imaginary * imaginary)
77
+ out_real = 0.0
78
+ out_imaginary = 0.0
79
+ if mode_volume.anybits?(0x10)
80
+ out_real += real / denominator
81
+ out_imaginary -= imaginary / denominator
82
+ end
83
+ if mode_volume.anybits?(0x20)
84
+ out_real += ratio * imaginary / denominator
85
+ out_imaginary += ratio * real / denominator
86
+ end
87
+ if mode_volume.anybits?(0x40)
88
+ out_real -= ratio * ratio * real / denominator
89
+ out_imaginary += ratio * ratio * imaginary / denominator
90
+ end
91
+ magnitude = Math.sqrt((out_real * out_real) + (out_imaginary * out_imaginary))
92
+ 20.0 * Math.log([magnitude, 1.0e-6].max) / Math.log(10)
93
+ end
94
+ end
95
+ end
96
+ end