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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +125 -46
- data/exe/badline-ruby +9 -1
- data/lib/badline/address_bus/extra_sids.rb +30 -0
- data/lib/badline/address_bus/sid_slots.rb +42 -0
- data/lib/badline/address_bus.rb +5 -0
- data/lib/badline/audio/aiff.rb +2 -2
- data/lib/badline/audio/bare_player.rb +23 -12
- data/lib/badline/audio/cli.rb +153 -58
- data/lib/badline/audio/jukebox.rb +121 -36
- data/lib/badline/audio/machine_player.rb +25 -10
- data/lib/badline/audio/pcm_writer.rb +10 -6
- data/lib/badline/audio/playback.rb +6 -5
- data/lib/badline/audio/queued_tune.rb +89 -0
- data/lib/badline/audio/renderer.rb +57 -10
- data/lib/badline/audio/stereo.rb +97 -0
- data/lib/badline/audio/terminal.rb +39 -6
- data/lib/badline/audio/wav.rb +1 -1
- data/lib/badline/audio.rb +2 -0
- data/lib/badline/frontend/audio_sink.rb +6 -5
- data/lib/badline/frontend/boot.rb +1 -1
- data/lib/badline/frontend/buttons.rb +90 -0
- data/lib/badline/frontend/filter_view.rb +96 -0
- data/lib/badline/frontend/info_view.rb +120 -0
- data/lib/badline/frontend/painter.rb +175 -0
- data/lib/badline/frontend/player_commands.rb +33 -0
- data/lib/badline/frontend/player_footer.rb +86 -0
- data/lib/badline/frontend/player_header.rb +60 -0
- data/lib/badline/frontend/player_screen.rb +124 -0
- data/lib/badline/frontend/player_state.rb +67 -0
- data/lib/badline/frontend/player_window.rb +228 -0
- data/lib/badline/frontend/scope.rb +42 -0
- data/lib/badline/frontend/sid_history.rb +175 -0
- data/lib/badline/frontend/sid_view.rb +140 -0
- data/lib/badline/frontend/visualizer_view.rb +101 -0
- data/lib/badline/frontend.rb +14 -0
- data/lib/badline/media/queue.rb +158 -0
- data/lib/badline/media.rb +13 -8
- data/lib/badline/options/help.rb +34 -5
- data/lib/badline/options/option.rb +1 -1
- data/lib/badline/options/table.rb +4 -2
- data/lib/badline/options/validation.rb +54 -0
- data/lib/badline/options.rb +62 -43
- data/lib/badline/sdl.rb +16 -6
- data/lib/badline/sid/catch_up.rb +11 -1
- data/lib/badline/sid/filter.rb +9 -0
- data/lib/badline/sid/voice.rb +6 -0
- data/lib/badline/sid/waveform.rb +20 -5
- data/lib/badline/sid.rb +35 -3
- data/lib/badline/storage/hvsc.rb +41 -0
- data/lib/badline/storage/sid_file.rb +45 -25
- data/lib/badline/storage/song_lengths.rb +6 -29
- data/lib/badline/storage/stil/credit.rb +50 -0
- data/lib/badline/storage/stil.rb +126 -0
- data/lib/badline/storage.rb +8 -0
- data/lib/badline/version.rb +1 -1
- metadata +24 -1
data/lib/badline/sid/catch_up.rb
CHANGED
|
@@ -96,7 +96,17 @@ module Badline
|
|
|
96
96
|
|
|
97
97
|
def record_sample(span)
|
|
98
98
|
sample = @decimator.push(current_sample, span)
|
|
99
|
-
|
|
99
|
+
return unless sample
|
|
100
|
+
|
|
101
|
+
@samples << sample
|
|
102
|
+
record_voices if @voice_samples
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def record_voices
|
|
106
|
+
voice_samples = @voice_samples
|
|
107
|
+
voice_samples << (@voice1.output >> 7)
|
|
108
|
+
voice_samples << (@voice2.output >> 7)
|
|
109
|
+
voice_samples << (@voice3.output >> 7)
|
|
100
110
|
end
|
|
101
111
|
|
|
102
112
|
def current_sample
|
data/lib/badline/sid/filter.rb
CHANGED
|
@@ -122,6 +122,15 @@ module Badline
|
|
|
122
122
|
reset
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
+
# The integrators and the registers carry over; the cutoff maps
|
|
126
|
+
# through the new model's curve.
|
|
127
|
+
def model=(model)
|
|
128
|
+
@model = model
|
|
129
|
+
@w0_table = W0.fetch(model)
|
|
130
|
+
@mixer_dc = MIXER_DC.fetch(model)
|
|
131
|
+
@w0 = @w0_table[@cutoff]
|
|
132
|
+
end
|
|
133
|
+
|
|
125
134
|
def reset
|
|
126
135
|
@cutoff = 0x000
|
|
127
136
|
@routing = 0x0
|
data/lib/badline/sid/voice.rb
CHANGED
|
@@ -33,6 +33,12 @@ module Badline
|
|
|
33
33
|
@dc_offset = DC_OFFSET.fetch(model)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
def model=(model)
|
|
37
|
+
@waveform.model = model
|
|
38
|
+
@wave_zero = WAVE_ZERO.fetch(model)
|
|
39
|
+
@dc_offset = DC_OFFSET.fetch(model)
|
|
40
|
+
end
|
|
41
|
+
|
|
36
42
|
def reset!
|
|
37
43
|
@waveform.reset!
|
|
38
44
|
@envelope = Envelope.new
|
data/lib/badline/sid/waveform.rb
CHANGED
|
@@ -54,17 +54,24 @@ module Badline
|
|
|
54
54
|
attr_reader :accumulator, :shift_register, :frequency, :pulse_width, :pulse
|
|
55
55
|
|
|
56
56
|
def initialize(model: :mos6581)
|
|
57
|
-
|
|
57
|
+
use_model(model)
|
|
58
58
|
@tri_saw_delay = model == :mos8580
|
|
59
|
-
@pulse_noise_read = PULSE_NOISE_READ.fetch(model)
|
|
60
|
-
@pulse_noise_write = PULSE_NOISE_WRITE.fetch(model)
|
|
61
|
-
@combined = Combined.tables(model)
|
|
62
|
-
@shift_register_reset_delay = SHIFT_REGISTER_RESET_DELAY.fetch(model)
|
|
63
59
|
@accumulator = POWER_ON_ACCUMULATOR
|
|
64
60
|
@sync_source = @sync_dest = self
|
|
65
61
|
reset!
|
|
66
62
|
end
|
|
67
63
|
|
|
64
|
+
# The running state carries over. A 6581 never delays its shapers, so
|
|
65
|
+
# the 8580's delayed triangle and sawtooth start from the current phase.
|
|
66
|
+
def model=(model)
|
|
67
|
+
use_model(model)
|
|
68
|
+
if model == :mos8580 && !@tri_saw_delay
|
|
69
|
+
@delayed_sawtooth = sawtooth
|
|
70
|
+
@delayed_triangle = triangle
|
|
71
|
+
end
|
|
72
|
+
@tri_saw_delay = model == :mos8580
|
|
73
|
+
end
|
|
74
|
+
|
|
68
75
|
# The RES line clears the registers and reseeds the LFSR, but leaves
|
|
69
76
|
# the accumulator alone (SID/oscinit).
|
|
70
77
|
def reset!
|
|
@@ -190,6 +197,14 @@ module Badline
|
|
|
190
197
|
|
|
191
198
|
private
|
|
192
199
|
|
|
200
|
+
def use_model(model)
|
|
201
|
+
@topbit_feedback = model != :mos8580
|
|
202
|
+
@pulse_noise_read = PULSE_NOISE_READ.fetch(model)
|
|
203
|
+
@pulse_noise_write = PULSE_NOISE_WRITE.fetch(model)
|
|
204
|
+
@combined = Combined.tables(model)
|
|
205
|
+
@shift_register_reset_delay = SHIFT_REGISTER_RESET_DELAY.fetch(model)
|
|
206
|
+
end
|
|
207
|
+
|
|
193
208
|
def shape(selected, saw = sawtooth, tri = triangle(selected))
|
|
194
209
|
case selected
|
|
195
210
|
when 0x1 then tri
|
data/lib/badline/sid.rb
CHANGED
|
@@ -16,7 +16,9 @@ module Badline
|
|
|
16
16
|
# $D41B-$D41C - Voice 3 oscillator/envelope - read only
|
|
17
17
|
# $D41D-$D41F - Unconnected
|
|
18
18
|
#
|
|
19
|
-
# The register file mirrors every 32 bytes up to $D7FF.
|
|
19
|
+
# The register file mirrors every 32 bytes up to $D7FF. A second or
|
|
20
|
+
# third SID, which only the SID player fits, is built `at` another
|
|
21
|
+
# address and decodes just the 32 bytes there.
|
|
20
22
|
#
|
|
21
23
|
# Reading a write-only or unconnected register returns the last byte the
|
|
22
24
|
# SID saw on the data bus. That value fades to $00 as the capacitance
|
|
@@ -55,8 +57,8 @@ module Badline
|
|
|
55
57
|
attr_accessor :pots
|
|
56
58
|
attr_reader :model
|
|
57
59
|
|
|
58
|
-
def initialize(model: :mos6581, pots: nil, filter_chunk: FILTER_CHUNK)
|
|
59
|
-
addressable_at(
|
|
60
|
+
def initialize(model: :mos6581, pots: nil, filter_chunk: FILTER_CHUNK, at: 0xd400)
|
|
61
|
+
addressable_at(at, length: at == 0xd400 ? 2**10 : 2**5)
|
|
60
62
|
@model = model
|
|
61
63
|
@pots = pots
|
|
62
64
|
@bus_ttl_reset = BUS_TTL.fetch(model)
|
|
@@ -67,6 +69,7 @@ module Badline
|
|
|
67
69
|
@deferred_writes = []
|
|
68
70
|
@decimator = nil
|
|
69
71
|
@samples = []
|
|
72
|
+
@voice_samples = nil
|
|
70
73
|
@filter_chunk = filter_chunk
|
|
71
74
|
@voices = Voice.linked(VOICES, model:)
|
|
72
75
|
@voice1, @voice2, @voice3 = @voices
|
|
@@ -74,6 +77,18 @@ module Badline
|
|
|
74
77
|
reset!
|
|
75
78
|
end
|
|
76
79
|
|
|
80
|
+
# Switches the chip, its filter and its voices to the other model's
|
|
81
|
+
# values. The cycles already passed run on the old model first, and the
|
|
82
|
+
# running state carries over.
|
|
83
|
+
def model=(model)
|
|
84
|
+
catch_up
|
|
85
|
+
@model = model
|
|
86
|
+
@bus_ttl_reset = BUS_TTL.fetch(model)
|
|
87
|
+
@filter.model = model
|
|
88
|
+
@voices.each { |voice| voice.model = model }
|
|
89
|
+
update_span_rules
|
|
90
|
+
end
|
|
91
|
+
|
|
77
92
|
# The RES line clears the registers, the data bus, the filter and the
|
|
78
93
|
# voices, all but their accumulators. Recording carries on across it.
|
|
79
94
|
def reset!
|
|
@@ -112,6 +127,23 @@ module Badline
|
|
|
112
127
|
@samples = []
|
|
113
128
|
end
|
|
114
129
|
|
|
130
|
+
# Also keeps each voice's own output, before the filter and the volume,
|
|
131
|
+
# as each recorded sample closes, for #drain_voice_samples.
|
|
132
|
+
def record_voices!
|
|
133
|
+
@voice_samples = [] if @voice_samples.nil?
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The voices' outputs since the last drain, three to a sample, in voice
|
|
137
|
+
# order, or nothing unless #record_voices! asked for them.
|
|
138
|
+
def drain_voice_samples
|
|
139
|
+
return [] if @voice_samples.nil?
|
|
140
|
+
|
|
141
|
+
catch_up
|
|
142
|
+
samples = @voice_samples
|
|
143
|
+
@voice_samples = []
|
|
144
|
+
samples
|
|
145
|
+
end
|
|
146
|
+
|
|
115
147
|
# The samples recorded since the last drain.
|
|
116
148
|
def drain_samples
|
|
117
149
|
catch_up
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
# The layout of an HVSC collection: tunes in directories under its root,
|
|
6
|
+
# and databases about them in `DOCUMENTS`. A tune's HVSC path is its path
|
|
7
|
+
# from the root, as `/MUSICIANS/H/Hubbard_Rob/Commando.sid`.
|
|
8
|
+
module HVSC
|
|
9
|
+
class << self
|
|
10
|
+
# The named file in the DOCUMENTS directory of a collection above the
|
|
11
|
+
# tune, or else of the one `$HVSC_BASE` points at.
|
|
12
|
+
def document(tune_path, name)
|
|
13
|
+
candidates(tune_path).map { |dir| File.join(dir, "DOCUMENTS", name) }
|
|
14
|
+
.find { |path| File.file?(path) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The tune's HVSC path in the collection that holds the document, or
|
|
18
|
+
# nil when the tune lies outside it.
|
|
19
|
+
def path(tune_path, document)
|
|
20
|
+
root = File.dirname(File.expand_path(document), 2)
|
|
21
|
+
tune = File.expand_path(tune_path)
|
|
22
|
+
tune.start_with?("#{root}/") ? tune[root.length..] : nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def candidates(tune_path)
|
|
28
|
+
dirs = []
|
|
29
|
+
dir = File.dirname(File.expand_path(tune_path))
|
|
30
|
+
until dirs.last == dir
|
|
31
|
+
dirs << dir
|
|
32
|
+
dir = File.dirname(dir)
|
|
33
|
+
end
|
|
34
|
+
base = ENV.fetch("HVSC_BASE", nil)
|
|
35
|
+
dirs << base if base
|
|
36
|
+
dirs
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -16,7 +16,7 @@ module Badline
|
|
|
16
16
|
# BASIC's RUN handler, where a BASIC tune starts.
|
|
17
17
|
BASIC_RUN = 0xa871
|
|
18
18
|
|
|
19
|
-
# The 6502 stub that starts a tune: call init with the
|
|
19
|
+
# The 6502 stub that starts a tune: call init with the subtune number,
|
|
20
20
|
# point the KERNAL's IRQ vector at a raster handler that calls play,
|
|
21
21
|
# and spin. Both calls are wrapped in the `$01` save, bank and restore
|
|
22
22
|
# that puts the tune's own RAM under the CPU.
|
|
@@ -41,7 +41,7 @@ module Badline
|
|
|
41
41
|
# pha
|
|
42
42
|
# lda #iomap
|
|
43
43
|
# sta $01
|
|
44
|
-
# lda #
|
|
44
|
+
# lda #subtune
|
|
45
45
|
# jsr init
|
|
46
46
|
# pla
|
|
47
47
|
# sta $01
|
|
@@ -70,9 +70,9 @@ module Badline
|
|
|
70
70
|
CIA1_ICR = 0xdc0d
|
|
71
71
|
RASTER_IRQ_STATUS = 0xd019
|
|
72
72
|
|
|
73
|
-
def initialize(tune,
|
|
73
|
+
def initialize(tune, subtune:, base:)
|
|
74
74
|
@tune = tune
|
|
75
|
-
@
|
|
75
|
+
@subtune = subtune
|
|
76
76
|
@base = base
|
|
77
77
|
end
|
|
78
78
|
|
|
@@ -96,7 +96,7 @@ module Badline
|
|
|
96
96
|
|
|
97
97
|
def boot
|
|
98
98
|
start = [0x78] +
|
|
99
|
-
banked(@tune.init_address, lda_imm(@
|
|
99
|
+
banked(@tune.init_address, lda_imm(@subtune) + jsr(@tune.init_address)) +
|
|
100
100
|
raster_irq + [0x58]
|
|
101
101
|
start + jmp(boot_address + start.length)
|
|
102
102
|
end
|
|
@@ -153,14 +153,13 @@ module Badline
|
|
|
153
153
|
raise FormatError, "Truncated header" if truncated?
|
|
154
154
|
raise FormatError, "No tune data" if data.empty?
|
|
155
155
|
raise FormatError, "Sidplayer MUS data, which needs a MUS player" if mus?
|
|
156
|
-
raise FormatError, "Written for #{sids} SIDs; only one is emulated" if sids > 1
|
|
157
156
|
end
|
|
158
157
|
|
|
159
158
|
def version = word(0x04)
|
|
160
159
|
def data_offset = word(0x06)
|
|
161
160
|
def play_address = word(0x0c)
|
|
162
|
-
def
|
|
163
|
-
def
|
|
161
|
+
def subtunes = [word(0x0e), 1].max
|
|
162
|
+
def start_subtune = word(0x10).clamp(1, subtunes)
|
|
164
163
|
def speed = (word(0x12) << 16) + word(0x14)
|
|
165
164
|
def name = text(0x16)
|
|
166
165
|
def author = text(0x36)
|
|
@@ -181,22 +180,38 @@ module Badline
|
|
|
181
180
|
# A v3 header can place a second SID at `$Dxx0` and a v4 header a
|
|
182
181
|
# third, each given by its middle byte: even, and in `$d420-$d7e0` or
|
|
183
182
|
# `$de00-$dfe0`. Anything else means the SID isn't there.
|
|
184
|
-
def sids
|
|
185
|
-
|
|
183
|
+
def sids = 1 + sid_addresses.size
|
|
184
|
+
|
|
185
|
+
# Where the second and third SIDs sit, those the header places.
|
|
186
|
+
def sid_addresses
|
|
187
|
+
addresses = []
|
|
188
|
+
[0x7a, 0x7b].first([version - 2, 0].max).each do |offset|
|
|
186
189
|
page = @bytes[offset].to_i
|
|
187
|
-
page.even? && ((0x42..0x7e).cover?(page) || (0xe0..0xfe).cover?(page))
|
|
190
|
+
next unless page.even? && ((0x42..0x7e).cover?(page) || (0xe0..0xfe).cover?(page))
|
|
191
|
+
|
|
192
|
+
addresses << (0xd000 | (page << 4))
|
|
188
193
|
end
|
|
194
|
+
addresses
|
|
189
195
|
end
|
|
190
196
|
|
|
191
197
|
# Flag bits 4-5 name the SID the tune was written for: 01 the 6581,
|
|
192
198
|
# 10 the 8580, 11 either and 00 unknown. Only an 8580-only tune gets one.
|
|
193
199
|
def sid_model = flags[4, 2] == 0b10 ? :mos8580 : :mos6581
|
|
194
200
|
|
|
201
|
+
# Each SID's model, SID 1's first. Bits 6-7 name the second SID's and
|
|
202
|
+
# bits 8-9 the third's the same way, and one the header leaves unknown
|
|
203
|
+
# or open takes SID 1's.
|
|
204
|
+
def sid_models
|
|
205
|
+
models = [sid_model]
|
|
206
|
+
sid_addresses.size.times { |extra| models << model_named(flags[6 + (extra * 2), 2]) }
|
|
207
|
+
models
|
|
208
|
+
end
|
|
209
|
+
|
|
195
210
|
# Flag bits 2-3 name the video standard the same way: 01 PAL, 10 NTSC,
|
|
196
211
|
# 11 either and 00 unknown. Only an NTSC-only tune gets NTSC.
|
|
197
212
|
def ntsc? = flags[2, 2] == 0b10
|
|
198
213
|
|
|
199
|
-
# HVSC keys its
|
|
214
|
+
# HVSC keys its subtune length database on the whole file, header and all.
|
|
200
215
|
def md5 = @md5 ||= Digest::MD5.hexdigest(@bytes.pack("C*"))
|
|
201
216
|
|
|
202
217
|
# The `$01` value a routine at `address` has to run under, following
|
|
@@ -227,8 +242,8 @@ module Badline
|
|
|
227
242
|
# A set speed bit asks for CIA timer pacing instead of a raster IRQ.
|
|
228
243
|
# The bare player honours it; the machine driver paces everything off
|
|
229
244
|
# the raster either way.
|
|
230
|
-
def cia_timed?(
|
|
231
|
-
speed[[
|
|
245
|
+
def cia_timed?(subtune = start_subtune)
|
|
246
|
+
speed[[subtune - 1, 31].min] == 1
|
|
232
247
|
end
|
|
233
248
|
|
|
234
249
|
def driver_address
|
|
@@ -236,20 +251,20 @@ module Badline
|
|
|
236
251
|
raise(FormatError, "No free RAM for the player")
|
|
237
252
|
end
|
|
238
253
|
|
|
239
|
-
def driver(
|
|
240
|
-
Driver.new(self,
|
|
254
|
+
def driver(subtune: start_subtune)
|
|
255
|
+
Driver.new(self, subtune: subtune.clamp(1, subtunes) - 1, base: driver_address).bytes
|
|
241
256
|
end
|
|
242
257
|
|
|
243
258
|
# What a booted machine needs in RAM besides the image before
|
|
244
259
|
# `boot_command` starts the tune, as address => bytes. A BASIC tune
|
|
245
260
|
# gets the end of its program in VARTAB, as LOAD leaves it, and the
|
|
246
|
-
#
|
|
261
|
+
# subtune in the A, X and Y that SYS loads from `$030c-$030e`, where the
|
|
247
262
|
# tune reads it with PEEK(780).
|
|
248
|
-
def boot_memory(
|
|
249
|
-
return { driver_address => driver(
|
|
263
|
+
def boot_memory(subtune: start_subtune)
|
|
264
|
+
return { driver_address => driver(subtune:) } unless basic?
|
|
250
265
|
|
|
251
266
|
{ 0x2d => [low_byte(end_address), high_byte(end_address)],
|
|
252
|
-
0x030c => [
|
|
267
|
+
0x030c => [subtune.clamp(1, subtunes) - 1] * 3 }
|
|
253
268
|
end
|
|
254
269
|
|
|
255
270
|
def boot_command = basic? ? "run\r" : "sys#{driver_address}\r"
|
|
@@ -289,14 +304,19 @@ module Badline
|
|
|
289
304
|
end
|
|
290
305
|
|
|
291
306
|
def driver_size
|
|
292
|
-
@driver_size ||= Driver.new(self,
|
|
307
|
+
@driver_size ||= Driver.new(self, subtune: 0, base: 0).bytes.length
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def model_named(bits)
|
|
311
|
+
case bits
|
|
312
|
+
when 0b01 then :mos6581
|
|
313
|
+
when 0b10 then :mos8580
|
|
314
|
+
else sid_model
|
|
315
|
+
end
|
|
293
316
|
end
|
|
294
317
|
|
|
295
318
|
def text(offset)
|
|
296
|
-
@bytes[offset, 32].take_while(&:positive?)
|
|
297
|
-
.pack("C*")
|
|
298
|
-
.force_encoding(Encoding::ISO_8859_1)
|
|
299
|
-
.encode(Encoding::UTF_8)
|
|
319
|
+
Storage.latin1(@bytes[offset, 32].take_while(&:positive?))
|
|
300
320
|
end
|
|
301
321
|
|
|
302
322
|
# Header fields are big-endian, unlike everything else the 6502 sees.
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Badline
|
|
4
4
|
module Storage
|
|
5
|
-
# HVSC's
|
|
5
|
+
# HVSC's subtune length database. Each tune gets a comment naming its path
|
|
6
6
|
# and a line keyed by the MD5 of the whole `.sid` file, holding one
|
|
7
|
-
# `m:ss` time per
|
|
7
|
+
# `m:ss` time per subtune, optionally down to milliseconds:
|
|
8
8
|
#
|
|
9
9
|
# ; /DEMOS/0-9/12th_Sector_Music.sid
|
|
10
10
|
# c7c299ce06ec5ccffb2261fb11b42a73=4:33.108
|
|
@@ -12,38 +12,15 @@ module Badline
|
|
|
12
12
|
# The file runs to well over a hundred thousand entries, so a lookup
|
|
13
13
|
# scans for its one key rather than building a table.
|
|
14
14
|
class SongLengths
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
# `$HVSC_BASE` points.
|
|
19
|
-
def locate(tune_path)
|
|
20
|
-
candidates(tune_path).find { |path| File.file?(path) }
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
private
|
|
24
|
-
|
|
25
|
-
def candidates(tune_path)
|
|
26
|
-
ancestors(File.dirname(File.expand_path(tune_path)))
|
|
27
|
-
.push(ENV.fetch("HVSC_BASE", nil))
|
|
28
|
-
.compact
|
|
29
|
-
.map { |dir| File.join(dir, "DOCUMENTS", "Songlengths.md5") }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def ancestors(dir)
|
|
33
|
-
dirs = []
|
|
34
|
-
until dirs.last == dir
|
|
35
|
-
dirs << dir
|
|
36
|
-
dir = File.dirname(dir)
|
|
37
|
-
end
|
|
38
|
-
dirs
|
|
39
|
-
end
|
|
40
|
-
end
|
|
15
|
+
# The database lives in the DOCUMENTS directory of an HVSC
|
|
16
|
+
# collection.
|
|
17
|
+
def self.locate(tune_path) = HVSC.document(tune_path, "Songlengths.md5")
|
|
41
18
|
|
|
42
19
|
def initialize(path)
|
|
43
20
|
@path = path
|
|
44
21
|
end
|
|
45
22
|
|
|
46
|
-
# The tune's per-
|
|
23
|
+
# The tune's per-subtune lengths in seconds, or nil when it isn't listed.
|
|
47
24
|
def lengths(md5)
|
|
48
25
|
prefix = "#{md5.downcase}="
|
|
49
26
|
File.foreach(@path) do |line|
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Badline
|
|
4
|
+
module Storage
|
|
5
|
+
class STIL
|
|
6
|
+
# A TITLE in a STIL entry, the tune a subtune covers, with the ARTIST
|
|
7
|
+
# after it, and the stretch of the subtune it covers when the title
|
|
8
|
+
# ends in one, such as `(0:53)` or `(1:16-1:32)`. A credit without an
|
|
9
|
+
# end lasts until the next one takes over.
|
|
10
|
+
class Credit
|
|
11
|
+
TIMES = /\s*\((\d+):(\d\d)(?:\.\d+)?(?:-(\d+):(\d\d)(?:\.\d+)?)?\)\s*\z/
|
|
12
|
+
|
|
13
|
+
attr_reader :text, :start, :finish
|
|
14
|
+
|
|
15
|
+
def initialize(title, artist)
|
|
16
|
+
match = TIMES.match(title)
|
|
17
|
+
@start = match ? (match[1].to_i * 60) + match[2].to_i : 0
|
|
18
|
+
@finish = match && match[3] ? (match[3].to_i * 60) + match[4].to_i : -1
|
|
19
|
+
name = match ? title[0, match.begin(0)] : title
|
|
20
|
+
@text = artist.empty? ? name : "#{name} - #{artist}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The credits in the fields, in the order STIL lists them.
|
|
24
|
+
def self.list(fields)
|
|
25
|
+
credits = []
|
|
26
|
+
fields.each_with_index do |field, index|
|
|
27
|
+
next unless field.name == "TITLE"
|
|
28
|
+
|
|
29
|
+
following = fields[index + 1]
|
|
30
|
+
artist = following && following.name == "ARTIST" ? following.text : ""
|
|
31
|
+
credits << new(field.text.tr("\n", " "), artist.tr("\n", " "))
|
|
32
|
+
end
|
|
33
|
+
credits
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The text of the credit covering `seconds` into the subtune, or ""
|
|
37
|
+
# when none does: the last one started by then that hasn't ended.
|
|
38
|
+
def self.at(credits, seconds)
|
|
39
|
+
text = ""
|
|
40
|
+
credits.each do |credit|
|
|
41
|
+
text = credit.text if credit.covers?(seconds)
|
|
42
|
+
end
|
|
43
|
+
text
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def covers?(seconds) = seconds >= @start && (@finish.negative? || seconds <= @finish)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "badline/storage/stil/credit"
|
|
4
|
+
|
|
5
|
+
module Badline
|
|
6
|
+
module Storage
|
|
7
|
+
# HVSC's SID Tune Information List, `DOCUMENTS/STIL.txt`: covers,
|
|
8
|
+
# subtune names and composers' comments, keyed by each tune's HVSC path.
|
|
9
|
+
# An entry holds fields for the whole file, then a block per subtune
|
|
10
|
+
# that has any:
|
|
11
|
+
#
|
|
12
|
+
# /MUSICIANS/C/Crowther_Anthony/From_Ratt_To_You.sid
|
|
13
|
+
# COMMENT: The tune names are listed, if they were given a name, the
|
|
14
|
+
# remaining tunes were just called Tune 35, Tune 27 etc.
|
|
15
|
+
# (#16)
|
|
16
|
+
# NAME: G-Bust
|
|
17
|
+
# TITLE: Ghostbusters [from the movie]
|
|
18
|
+
# ARTIST: Ray Parker, Jr.
|
|
19
|
+
#
|
|
20
|
+
# A comment runs on over lines indented by nine spaces, and a blank line
|
|
21
|
+
# ends the entry. The file is ISO-8859-1.
|
|
22
|
+
class STIL
|
|
23
|
+
# One of NAME, AUTHOR, TITLE, ARTIST or COMMENT, with its lines of
|
|
24
|
+
# text joined by newlines.
|
|
25
|
+
class Field
|
|
26
|
+
attr_reader :name, :text
|
|
27
|
+
|
|
28
|
+
def initialize(name, text)
|
|
29
|
+
@name = name
|
|
30
|
+
@text = text
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def continue(line)
|
|
34
|
+
@text = "#{@text}\n#{line}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# The field laid out as STIL.txt does.
|
|
38
|
+
def lines
|
|
39
|
+
@text.split("\n").each_with_index.map do |line, index|
|
|
40
|
+
label = index.zero? ? "#{@name}:" : ""
|
|
41
|
+
"#{label.rjust(8)} #{line}"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A tune's fields for the whole file, and for each subtune by number.
|
|
47
|
+
class Entry
|
|
48
|
+
attr_reader :fields
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@fields = []
|
|
52
|
+
@subtunes = {}
|
|
53
|
+
@subtune = 0
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def subtune(number) = @subtunes.fetch(number, [])
|
|
57
|
+
|
|
58
|
+
# Starts the block for a subtune, which the fields read after it
|
|
59
|
+
# go to.
|
|
60
|
+
def open_subtune(subtune)
|
|
61
|
+
@subtune = subtune
|
|
62
|
+
@subtunes[subtune] = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def add(field)
|
|
66
|
+
if @subtune.zero?
|
|
67
|
+
@fields << field
|
|
68
|
+
else
|
|
69
|
+
@subtunes[@subtune] << field
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Adds a line to the last field read.
|
|
74
|
+
def continue(line)
|
|
75
|
+
last = @subtune.zero? ? @fields.last : @subtunes[@subtune].last
|
|
76
|
+
last&.continue(line)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The list lives in the DOCUMENTS directory of an HVSC collection.
|
|
81
|
+
def self.locate(tune_path) = HVSC.document(tune_path, "STIL.txt")
|
|
82
|
+
|
|
83
|
+
def initialize(path)
|
|
84
|
+
@path = path
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The entry for a tune's HVSC path, or nil when the list has none. The
|
|
88
|
+
# first lookup reads the whole list.
|
|
89
|
+
def entry(hvsc_path) = entries[hvsc_path]
|
|
90
|
+
|
|
91
|
+
def entries
|
|
92
|
+
@entries ||= parse(Storage.latin1(File.binread(@path).bytes))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def parse(text)
|
|
98
|
+
entries = {}
|
|
99
|
+
entry = nil
|
|
100
|
+
text.split("\n").each do |line|
|
|
101
|
+
line = line.chomp
|
|
102
|
+
if line.start_with?("/")
|
|
103
|
+
entry = Entry.new
|
|
104
|
+
entries[line] = entry
|
|
105
|
+
elsif entry.nil? || line.empty? || line.start_with?("#")
|
|
106
|
+
entry = nil
|
|
107
|
+
else
|
|
108
|
+
read(entry, line)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
entries
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def read(entry, line)
|
|
115
|
+
if line.start_with?("(#")
|
|
116
|
+
entry.open_subtune(line[2..].to_i)
|
|
117
|
+
elsif line.start_with?(" ")
|
|
118
|
+
entry.continue(line[9..])
|
|
119
|
+
else
|
|
120
|
+
name, text = line.lstrip.split(": ", 2)
|
|
121
|
+
entry.add(Field.new(name, text.to_s)) if %w[NAME AUTHOR TITLE ARTIST COMMENT].include?(name)
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
data/lib/badline/storage.rb
CHANGED
|
@@ -12,7 +12,9 @@ require "badline/storage/t64"
|
|
|
12
12
|
require "badline/storage/tap"
|
|
13
13
|
require "badline/storage/crt_file"
|
|
14
14
|
require "badline/storage/sid_file"
|
|
15
|
+
require "badline/storage/hvsc"
|
|
15
16
|
require "badline/storage/song_lengths"
|
|
17
|
+
require "badline/storage/stil"
|
|
16
18
|
require "badline/storage/unavailable"
|
|
17
19
|
|
|
18
20
|
module Badline
|
|
@@ -62,6 +64,12 @@ module Badline
|
|
|
62
64
|
bytes.map { |b| b.between?(0xc1, 0xda) ? b - 0x80 : b }.pack("C*")
|
|
63
65
|
end
|
|
64
66
|
|
|
67
|
+
# Decodes ISO-8859-1 bytes, the text of .sid headers and HVSC's
|
|
68
|
+
# documents, to UTF-8.
|
|
69
|
+
def latin1(bytes)
|
|
70
|
+
bytes.pack("U*").force_encoding(Encoding::UTF_8)
|
|
71
|
+
end
|
|
72
|
+
|
|
65
73
|
# CBM DOS drive prefix: "0:NAME" selects a drive, "@0:NAME" is
|
|
66
74
|
# save-with-replace
|
|
67
75
|
def strip_drive_prefix(name)
|
data/lib/badline/version.rb
CHANGED