Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Changes In Branch merge-playablefile-virtualfile Excluding Merge-Ins

This is equivalent to a diff from 13b4854065 to b1f5b6074e

2024-08-05
05:01
Merge merge-playablefile-virtualfile (but don't integrate). Update NEWS. check-in: 1986a4b965 user: alexa tags: trunk
04:57
Update TRUNKSTATUS Closed-Leaf check-in: b1f5b6074e user: alexa tags: merge-playablefile-virtualfile
04:56
Add a TODO check-in: 20ae9e67c9 user: alexa tags: merge-playablefile-virtualfile
03:57
Merge the VirtualFile class into the PlayableFile class. Remove HTTP/Gemini loading. The two classes were overlapping in functionality. By combining them, we simplify some of the file handling code. check-in: 32db9d5345 user: alexa tags: merge-playablefile-virtualfile
01:19
Add a check for the UI theme being an array check-in: 13b4854065 user: alexa tags: trunk
01:18
Check for an empty array as a theme. Use the default theme in this case. check-in: 4bfcddb0b8 user: alexa tags: trunk

Changes to TRUNKSTATUS.
14
15
16
17
18
19
20
21
22


23
24
25
26
27
28
29
14
15
16
17
18
19
20


21
22
23
24
25
26
27
28
29







-
-
+
+







Loading
=======

* VGM loading: working
* Module loading: working
* XSPF/JSPF: working, seems to use more RAM than I would like
* Local files: working
* HTTP: working only for VGM
* Gemini: working only for VGM
* HTTP: removed
* Gemini: removed

=============
Audio/Formats
=============

* VGMs: working
* Modules (libxmp): working
Changes to src/audio-formats/flacfile.cr.
24
25
26
27
28
29
30
31
32


33

34
35
36
37
38
39
40
41
42
43
44
45
46

47
48
49
50
51
52
53
54











55
56
57
58
59
60
61
24
25
26
27
28
29
30


31
32
33
34
35
36
37
38
39
40
41
42
43
44
45


46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72







-
-
+
+

+











-
-
+








+
+
+
+
+
+
+
+
+
+
+







  # reading it from a file.  The file can optionally be compressed with Gzip,
  # Bzip2, or ZStandard.
  class FlacFile < PlayableFile
    @io : IO?
    @flac : RAFlac::StreamedFlac?
    @flacDec : RAFlac::StreamedDecoder?

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def self.test(filename : Path|String) : Bool
      File.open(filename, "rb") do |io|
        validFlac?, flacChan, _, _ = RAFlac::AbstractFlac.testInfo(io)
        validFlac? && flacChan == 2 # We only support stereo FLAC files
      end
    rescue Exception
      false
    end

    @[AlwaysInline]
    def ensureFile : Nil
    def ensureFile : Bool
      if @flac.nil?
        if @io.nil?
          @io = File.open(@filename, "rb")
        else
          @io.not_nil!.close
        end
        @flac = RAFlac::StreamedFlac.new(@io.not_nil!)
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load FLAC #{@filename}: #{err}")
      false
    end

    def unload : Nil
      Benben.dlog!("Unloading FLAC file: #{@filename}")
      @flac = nil
      @io = nil
      @flacDec = nil
    end

    def flac : RAFlac::StreamedFlac
      ensureFile
      @flac.not_nil!
    end

Changes to src/audio-formats/midifile.cr.
19
20
21
22
23
24
25

26
27
28
29
30
31
32
33
34
35


36

37
38




39



40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

























64
65
66
67
68
69
70
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34


35
36
37
38
39
40
41
42
43
44
45
46
47
48
























49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80







+








-
-
+
+

+


+
+
+
+

+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







#### Simple wrapper for MIDI files.
####

module Benben
  class MidiFile < PlayableFile
    class_getter! globalSoundfont : Haematite::SoundFont?
    class_getter globalSoundfontPath : String = ""
    @@globalSoundfontMut : Mutex = Mutex.new

    getter! soundfont : Haematite::SoundFont?
    getter soundfontPath : String = ""
    getter! synth : Haematite::Synthesizer?
    getter! seq : Haematite::Sequencer?
    @file : Haematite::SequencedFile?
    @arraypool : RemiLib::ArrayPool(Float64) = RemiLib::ArrayPool(Float64).new(0.0)

    def initialize(@filename : Path|String)
      #ensureFile
    protected def initialize(@filename : String)
      MidiFile.test(@filename) || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def self.test(filename : Path|String) : Bool
      Haematite::SequencedFile.validFile?(filename)
    end

    private def maybeSetGlobalSoundfont(path : Path|String) : Bool
      # TODO: This technically does not set @@globalSoundfontPath to
      # Benben.config.midiAll[0].soundfont (the actual real global soundfont).
      @@globalSoundfontMut.synchronize do
      # We can use the global SoundFont if these conditions hold:
      #   1. No global SoundFont is loaded
      #   2. The global SoundFont is not an MmapSoundFont (which it never should
      #      be)
      #   3. The global SoundFont path is empty, or the same as the requested
      #      path.
      if @@globalSoundfontPath == ""
        if @@globalSoundfont.nil?
          @@globalSoundfont = Haematite::SoundFont.new(path)
          @@globalSoundfontPath = path
          @soundfont = @@globalSoundfont
          true
        else
          raise "We have a global SoundFont path, but no global SoundFont"
        end
      elsif @@globalSoundfontPath == path
        if @@globalSoundfont.is_a?(Haematite::MmapSoundFont)
          raise "Global SoundFont somehow set to an MmapSoundFont"
        else
          @soundfont = @@globalSoundfont
          true
        end
      else
        false
        # We can use the global SoundFont if these conditions hold:
        #   1. No global SoundFont is loaded
        #   2. The global SoundFont is not an MmapSoundFont (which it never should
        #      be)
        #   3. The global SoundFont path is empty, or the same as the requested
        #      path.
        if @@globalSoundfontPath == ""
          if @@globalSoundfont.nil?
            @@globalSoundfont = Haematite::SoundFont.new(path)
            @@globalSoundfontPath = path
            @soundfont = @@globalSoundfont
            true
          else
            raise "We have a global SoundFont path, but no global SoundFont"
          end
        elsif @@globalSoundfontPath == path
          if @@globalSoundfont.is_a?(Haematite::MmapSoundFont)
            raise "Global SoundFont somehow set to an MmapSoundFont"
          else
            @soundfont = @@globalSoundfont
            true
          end
        else
          false
        end
      end
    end

    # Sets the SoundFont for this `MidiFile` instance.  This method never
    # utilizes a global SoundFont to save RAM.
    def soundfont=(path : String|Path) : Nil
      raise "Empty SoundFont path" if path.strip.empty?
92
93
94
95
96
97
98


















99
100







101
102
103
104
105
106
107






108
109
110
111

112
113
114
115
116

117
118
119
120

121
122
123
124
125
126
127
128
129
130
131
132

133
134
135
136
137
138
139
140
141
142

143
144
145
146
147
148
149

150
151
152
153
154
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126


127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149

150
151
152
153
154

155
156
157
158
159
160
161
162
163
164
165
166
167

168
169
170

171
172
173
174
175
176
177
178
179
180

181
182
183
184
185
186
187

188
189
190
191
192
193







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+







+
+
+
+
+
+



-
+




-
+




+







-



-
+









-
+






-
+





        if (sf = @soundfont).is_a?(Haematite::MmapSoundFont)
          sf.close
        end
        @soundfont = Haematite::SoundFont.new(@soundfontPath)
      end
    end

    def ensureFile : Bool
      # KLUDGE: MIDI files are handled differently because of how SoundFonts are
      # loaded.  The ensureFile method loads only the MIDI file into RAM without
      # creating a MIDI synthesizer.
      #
      # To actually ensure the file is in RAM and a synth is created, the
      # ensureFileInRAM method is instead used, but only internally.  This must
      # be done after a SoundFont is loaded.
      ensureMIDIFileInRAM
      true
    rescue err : Exception
      {% if flag?(:benben_debug) %}
        RemiLib.log.error("Cannot load MIDI #{@filename}: #{err}\nBacktrace:\n#{err.backtrace.join('\n')}")
      {% else %}
        RemiLib.log.error("Cannot load MIDI #{@filename}: #{err}")
      {% end %}
      false
    end
    @[AlwaysInline]
    private def ensureFile : Nil

    private def ensureMIDIFileInRAM : Nil
      @file = Haematite::SequencedFile.load(@filename) if @file.nil?
    end

    private def ensureFileInRAM : Nil
      RemiLib.assert("No SoundFont set", !@soundfont.nil?)
      if @synth.nil?
        @synth = Haematite::Synthesizer.new(@soundfont.not_nil!, Benben.config.sampleRate.to_i32)
        @synth.not_nil!.applySettings(Benben.config.midi.toSynthSettings)
      end

      @seq = Haematite::Sequencer.new(@synth.not_nil!) if @seq.nil?
      @file = Haematite::SequencedFile.load(@filename) if @file.nil?
    rescue err : Exception
      {% if flag?(:benben_debug) %}
        RemiLib.log.error("Cannot load MIDI #{@filename}: #{err}\nBacktrace:\n#{err.backtrace.join('\n')}")
      {% else %}
        RemiLib.log.error("Cannot load MIDI #{@filename}: #{err}")
      {% end %}
    end

    def totalSamples : UInt64
      ensureFile
      ensureMIDIFileInRAM
      ((@file.not_nil!.length / 1000000) * Benben.config.sampleRate).to_u64!
    end

    def position : UInt64
      ensureFile
      ensureFileInRAM
      ((@seq.not_nil!.position[0] / 1000000) * Benben.config.sampleRate).to_u64!
    end

    def unload : Nil
      Benben.dlog!("Unloading MIDI file: #{@filename}")
      @file = nil
      @seq = nil
      @synth = nil
      if (sf = @soundfont).is_a?(Haematite::MmapSoundFont)
        sf.clearPages
      end
      @soundfont = nil
      super
    end

    def play(loop? : Bool) : Nil
      ensureFile
      ensureFileInRAM
      @seq.not_nil!.play(@file.not_nil!, loop?)
    end

    def finished? : Bool
      @seq.not_nil!.finished?
    end

    @[AlwaysInline]
    def decode(dest : Array(Float32)) : Int64
      ensureFile
      ensureFileInRAM
      @seq.not_nil!.render(dest, @arraypool)
      dest.size.to_i64!
    end

    @[AlwaysInline]
    def decode(dest : Array(Float64)) : Int64
      ensureFile
      ensureFileInRAM
      @seq.not_nil!.render(dest, @arraypool)
      dest.size.to_i64!
    end
  end
end
Changes to src/audio-formats/modulefile.cr.
26
27
28
29
30
31
32
33
34


35

36
37
38
39
40
41
42
26
27
28
29
30
31
32


33
34
35
36
37
38
39
40
41
42
43







-
-
+
+

+







  class ModuleFile < PlayableFile
    alias Xmp = RemiXmp

    @ctx : Xmp::Context = Xmp::Context.new
    @loaded : Bool = false
    @dtinfo : RemiXmp::ModuleInfo?

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def self.test(filename : Path|String) : Bool
      info = RemiXmp.test(filename)
      !info[0].starts_with?("ID3")
    end

76
77
78
79
80
81
82
83
84

85
86
87
88




89
90
91
92
93
94
95
77
78
79
80
81
82
83


84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99







-
-
+




+
+
+
+







        else
          IO.copy(file, io)
        end
      end
      io.to_slice
    end

    @[AlwaysInline]
    private def ensureFile : Nil
    def ensureFile : Bool
      unless @loaded
        @ctx.load(loadData)
        @loaded = true
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load module file #{@filename}: #{err}")
      false
    end

    def defaultPan=(value) : Nil
      raise "Too late to set panning" if @loaded
      @ctx.defaultPan = value
    end

121
122
123
124
125
126
127

128
129
130
131
132
133
134
135
136
137
138
125
126
127
128
129
130
131
132
133
134
135

136
137
138
139
140
141
142







+



-







    end

    def volume=(value)
      @ctx.volume = value
    end

    def unload : Nil
      Benben.dlog!("Unloading module file: #{@filename}")
      @dtinfo = nil
      @ctx.unload if @loaded
      @loaded = false
      super
    end

    def totalSamples : UInt64
      @ctx.info.lengthSamples
    end

    def pattern
Changes to src/audio-formats/mpeg1file.cr.
20
21
22
23
24
25
26
27
28


29

30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45

46
47
48









49
50
51
52
53
54
55
20
21
22
23
24
25
26


27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44


45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64







-
-
+
+

+














-
-
+



+
+
+
+
+
+
+
+
+







#### Simple wrapper for MPEG-1 files.
####

module Benben
  class Mpeg1File < PlayableFile
    @decoder : RemiMpg123::Decoder?

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def finalize
      @decoder = nil
    end

    def self.test(filename : Path|String) : Bool
      if mpegInfo = RemiMpg123::Decoder.test(filename)
        mpegInfo.mode != Mpg123::MODE_MONO
      else
        false
      end
    end

    @[AlwaysInline]
    private def ensureFile : Nil
    def ensureFile : Bool
      if @decoder.nil?
        @decoder = RemiMpg123::Decoder.new(@filename)
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load MPEG-1 #{@filename}: #{err}")
      false
    end

    def unload : Nil
      Benben.dlog!("Unloading MPEG-1 file: #{@filename}")
      @decoder = nil
    end

    def replayGain=(mode : ReplayGain) : Nil
      ensureFile
      @decoder.not_nil!.rva = case mode
                              in .disabled? then Mpg123::RvaMode::Off
                              in .mix? then Mpg123::RvaMode::Mix
Changes to src/audio-formats/opusfile.cr.
36
37
38
39
40
41
42
43
44


45

46
47
48
49
50
51
52
36
37
38
39
40
41
42


43
44
45
46
47
48
49
50
51
52
53







-
-
+
+

+







    @outputGain : Float32 = 0.0f32
    @channels : UInt8 = 0u8
    @tagPacket : Bytes = Bytes.new(0)
    getter? needResampling : Bool = false
    @sampleRate : UInt32 = 48000
    @totalSamples : UInt64 = 0u64

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    # Tests to see if the file at *filename* is a supported Opus file.  Returns
    # `true` if it is, or `false` otherwise.
    #
    # This only checks up to the first 8192 bytes of the file.
    def self.test(filename : Path|String) : Bool
144
145
146
147
148
149
150
151

152
153
154
155
156
157




158
159
160
161
162
163
164
145
146
147
148
149
150
151

152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169







-
+






+
+
+
+







                   # Opus at 48Khz then resample.
                   @needResampling = true
                   @sampleRate = 48000
                   Opus::Decoder.new(48000, 2)
                 end
    end

    private def ensureFile : Nil
    def ensureFile : Bool
      if @demuxer.nil?
        RemiLib.assert(@decoder.nil?)
        startDecoder
      else
        RemiLib.assert(!@decoder.nil?)
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load Opus #{@filename}: #{err}")
      false
    end

    def demuxer : OggDemuxer
      ensureFile
      @demuxer.not_nil!
    end

202
203
204
205
206
207
208

209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
207
208
209
210
211
212
213
214
215
216
217
218

219
220
221
222
223
224
225
226
227
228







+




-











    def rewind : Nil
      unload
      ensureFile
    end

    def unload : Nil
      Benben.dlog!("Unloading Opus file: #{@filename}")
      @decoder = nil
      @demuxer = nil
      @io.try(&.close)
      @io = nil
      super
    end

    @[AlwaysInline]
    def decode(packet : Bytes, dest : Array(Float32)) : Int32
      ret = self.decoder.decode(packet, dest)
      dest.map!(&.*(@outputGain)) unless @outputGain == 1.0f32
      ret
    end
  end
end
Changes to src/audio-formats/pcmfile.cr.
19
20
21
22
23
24
25
26
27


28

29
30
31
32
33
34
35
36
37
38
39
40
41
42
43

44









45
46
47
48
49
50
51
19
20
21
22
23
24
25


26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42


43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60







-
-
+
+

+













-
-
+

+
+
+
+
+
+
+
+
+







#### Wrapper for PCM files (e.g. RIFF WAVE and Au files)
####

module Benben
  class PcmFile < PlayableFile
    @file : RemiAudio::Formats::AudioFile?

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def finalize
      @file = nil
    end

    def self.test(filename : Path|String) : Bool
      file = RemiAudio::Formats::AudioFile.open(filename)
      file.channels == 2
    rescue Exception
      false
    end

    @[AlwaysInline]
    private def ensureFile : Nil
    def ensureFile : Bool
      @file = RemiAudio::Formats::AudioFile.open(filename) if @file.nil?
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load PCM #{@filename}: #{err}")
      false
    end

    def unload : Nil
      Benben.dlog!("Unloading PCM file: #{@filename}")
      @file = nil
    end

    def type : String
      ensureFile
      case @file.not_nil!
      in RemiAudio::Formats::AuFile then "Sun Au"
      in RemiAudio::Formats::WavFile then "RIFF WAVE"
Changes to src/audio-formats/playablefile.cr.
9
10
11
12
13
14
15

16
17
18
19
20
21
22
23
















24

25


































































































26
27
28
29
30
31
32
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147







+








+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.
require "remixspf"
require "../taginfo"

####
#### Simple wrapper for MIDI files.
####

module Benben
  abstract class PlayableFile
    class PlayableFileError < BenbenError
    end

    # Where a `PlayableFile` comes from.
    enum Source
      # A local file.
      Local

      # A file that is downloaded over HTTP/HTTPS.
      #Http

      # A file that is downloaded over Gemini.
      #Gemini
    end

    #getter source : Source = Source::Local
    @filename : Path|String = ""
    getter filename : String = ""
    @taginfo : TagInfo?
    @timeLength : Time::Span?

    protected def self.getFileTypeGuess(filename : Path|String) : FileType?
      # Try to use the extension as an educated guess to speed this up.
      ext = case filename
            in Path then filename.extension
            in String then Path[filename].extension
            end
      case ext.downcase
      when ".vgzst", ".vgm", ".vgb", ".vgz"
        FileType::Vgm if VgmFile.test(filename)
      when ".ogg", ".oga"
        FileType::Vorbis if VorbisFile.test(filename)
      when ".opus"
        FileType::Opus if OpusFile.test(filename)
      when ".flac"
        FileType::Flac if FlacFile.test(filename)
      when ".mp3", ".mp2", ".mp1"
        FileType::Mpeg1 if Mpeg1File.test(filename)
      when ".wav", ".wave", ".au"
        FileType::Pcm if PcmFile.test(filename)
      when ".midi", ".mid", ".mus", ".rmi"
        FileType::Midi if MidiFile.test(filename)
      else nil # Using the extension didn't yield a result
      end
    rescue Exception
      nil
    end

    # Determines if **filename** is a file type that is supported by Benben, and
    # if it is a supported filename, is also a supported file.  This returns the
    # `FileType`, which will be `FileType::Unknown` if it's unsupported.
    def self.getFileType(filename : Path|String) : FileType
      ret : FileType = FileType::Unknown
      return ret if !File.exists?(filename) || !File.readable?(filename) # Easy out

      # Try to use a faster method first
      if luckyGuess = getFileTypeGuess(filename)
        return luckyGuess
      end

      # If we've made it here, then the filename extension didn't help us.  Do
      # more intensive checks.
      case
      when VgmFile.test(filename)
        ret = FileType::Vgm
      when MidiFile.test(filename)
        ret = FileType::Midi
      when RemiXmp.test(filename)
        ret = FileType::Module
      when VorbisFile.test(filename)
        ret = FileType::Vorbis
      when OpusFile.test(filename)
        ret = FileType::Opus
      when FlacFile.test(filename)
        ret = FileType::Flac
      when PcmFile.test(filename)
        ret = FileType::Pcm

      # This one can sometimes give false positives for module files, so do it
      # *AFTER* module testing
      when Mpeg1File.test(filename)
        ret = FileType::Mpeg1

      else Benben.dlog!("Couldn't determine filetype for loading: #{filename}")
      end

      Benben.dlog!("File type #{ret}: #{filename}") unless ret.unknown?
      ret
    rescue err : Exception
      Benben.dlog!("Could not determine file type due to an exception: #{err} (backtrace: #{err.backtrace}")
      FileType::Unknown
    end

    def self.create(filename : String) : PlayableFile?
      case getFileType(filename)
      in .vgm? then return VgmFile.new(filename)
      in .module? then return ModuleFile.new(filename)
      in .flac? then return FlacFile.new(filename)
      in .opus? then return OpusFile.new(filename)
      in .vorbis? then return VorbisFile.new(filename)
      in .mpeg1? then return Mpeg1File.new(filename)
      in .midi? then return MidiFile.new(filename)
      in .pcm? then return PcmFile.new(filename)
      in .unknown?
        RemiLib.log.error("Cannot play file: #{filename}")
        nil
      end
    rescue err : Yuno::YunoError
      Benben.dlog!("YunoSynth Error loading file: #{err} (#{err.backtrace}")
      RemiLib.log.error("Cannot load file: #{err}")
      nil

    rescue err : Exception
      Benben.dlog!("Error loading file: #{err} (#{err.backtrace}")
      RemiLib.log.error("Cannot load file: #{err}")
      nil
    end

    def filename : String
      case ret = @filename
      in String then ret
      in Path then ret.to_s
      end
    end
45
46
47
48
49
50
51








52
53
54
55

























160
161
162
163
164
165
166
167
168
169
170
171
172
173
174




175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199







+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
      else
        @taginfo = TagInfo.create(self)
      end
    end

    abstract def totalSamples : UInt64

    def timeLength : Time::Span
      if ret = @timeLength
        ret
      else
        calcTimeLength
        @timeLength.not_nil!
      end
    end
    def unload : Nil
    end
  end
end

    # Calculates the total time length of this track, storing the value in the
    # `@timeLength` field.
    #
    # The calculation takes into account the number of loops, and thus
    # temporarily loads any applicable song config into RAM.  This song config
    # is **NOT** applied to the global `EphemeralConfig` singleton
    # (`Benben.config`).
    #
    # This is not thread safe.
    protected def calcTimeLength : Nil
      songConf = Benben.config.getSongConfig(self.filename)
      loops = if songConf && songConf.maxLoops_present?
                songConf.maxLoops
              else
                Benben.config.maxLoopsAll[0]
              end

      @timeLength = ((self.totalSamples * Math.max(1, loops)) / self.sampleRate).seconds
    end

    abstract def ensureFile : Bool
    abstract def unload : Nil
  end
end
Changes to src/audio-formats/vgmfile.cr.
19
20
21
22
23
24
25
26
27
28
29

30
31
32


33

34
35
36

37
38
39
40
41

42
43
44
45
46
47

48
49
50
51
52













53
54
55
56
57
58
59
19
20
21
22
23
24
25

26
27
28
29
30


31
32
33
34
35
36

37
38
39
40
41

42
43
44
45
46
47

48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73







-



+

-
-
+
+

+


-
+




-
+





-
+





+
+
+
+
+
+
+
+
+
+
+
+
+








####
#### Simple wrapper for VGM files.
####

module Benben
  class VgmFile < PlayableFile
    @filename : Path|String = ""
    @fromIO : Bool = false
    @file : Yuno::VgmFile?
    @totalSamples : UInt64? = nil
    @samplesPerLoop : UInt64? = nil

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def initialize(@filename : Path|String, io : IO)
    def initialize(@filename : String, io : IO)
      @file = Yuno::VgmFile.fromIO(io)
      @fromIO = true
    end

    def self.validVgmFile?(filename : Path|String) : Bool
    def self.test(filename : Path|String) : Bool
      Yuno::VgmFile.validVgmFile?(filename)
    rescue Exception
      false
    end

    def ensureFile
    def ensureFile : Bool
      if @fromIO
        raise "@file was not set but @fromIO is true" if @file.nil?
      elsif @file.nil?
        @file = Yuno::VgmFile.load(@filename)
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load VGM #{@filename}: #{err}")
      false
    end

    def unload : Nil
      unless @fromIO
        Benben.dlog!("Unloading VGM file: #{@filename}")
        @file = nil
      else
        Benben.dlog!("Skipping unload of VGM file (@fromIO is true): #{@filename}")
      end
    end

    @[AlwaysInline]
    def file : Yuno::VgmFile
      ensureFile
      @file.not_nil!
    end
68
69
70
71
72
73
74











75
76






















82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99


100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121







+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
        ensureFile
        outputRateMul, outputRateDiv, _, _ = Yuno::VgmPlayer.calcResampling(Benben.config.sampleRate, @file.not_nil!)
        @totalSamples = Yuno::VgmFile.vgmSampleToPcmSample(@file.not_nil!.header.totalSamples.to_i64!,
                                                           outputRateDiv.to_i64!,
                                                           outputRateMul.to_i64!).to_u64!
      end
    end

    # Returns the total number of samples for each loop.
    def samplesPerLoop : UInt64
      if ret = @samplesPerLoop
        ret
      else
        ensureFile
        outputRateMul, outputRateDiv, _, _ = Yuno::VgmPlayer.calcResampling(Benben.config.sampleRate, @file.not_nil!)
        @samplesPerLoop = Yuno::VgmFile.vgmSampleToPcmSample(@file.not_nil!.header.loopSamples.to_i64!,
                                                             outputRateDiv.to_i64!,
                                                             outputRateMul.to_i64!).to_u64!
  end
end
      end
    end

    # :inherited:
    protected def calcTimeLength : Nil
      songConf = Benben.config.getSongConfig(self.filename)
      loops = if songConf && songConf.maxLoops_present?
                songConf.maxLoops
              else
                Benben.config.maxLoopsAll[0]
              end

      if loops <= 1
        @timeLength = (self.totalSamples / self.sampleRate).seconds
      else
        @timeLength = ((self.totalSamples + (self.samplesPerLoop * loops - 1)) / self.sampleRate).seconds
      end

      @timeLength = @timeLength.not_nil! + Benben.config.fadeoutSeconds.seconds
    end
  end
end
Changes to src/audio-formats/vorbisfile.cr.
32
33
34
35
36
37
38
39
40


41

42
43
44
45
46
47
48
32
33
34
35
36
37
38


39
40
41
42
43
44
45
46
47
48
49







-
-
+
+

+







    @decoder : Vorbis::Decoder?
    @channels : UInt8 = 0u8
    @tagPacket : Bytes = Bytes.new(0)
    @sampleRate : UInt32 = 0u32
    @totalSamples : UInt64 = 0u64
    @mutex : Mutex = Mutex.new

    def initialize(@filename : Path|String)
      ensureFile
    protected def initialize(@filename : String)
      ensureFile || raise PlayableFileError.new("Cannot load file: #{@filename}")
      @taginfo = TagInfo.create(self)
      _ = self.timeLength
    end

    def self.test(filename : Path|String) : Bool
      testVorb = VorbisFile.new(filename)
      testVorb.channels == 2 # We only support stereo Vorbis files
    rescue Exception
      false
97
98
99
100
101
102
103
104

105
106
107
108
109
110




111
112
113
114
115
116
117
98
99
100
101
102
103
104

105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122







-
+






+
+
+
+







        demux.rewind
        demux.nextPacket
        demux.nextPacket
        demux.nextPacket
      end
    end

    private def ensureFile : Nil
    def ensureFile : Bool
      if @demuxer.nil?
        RemiLib.assert(@decoder.nil?)
        startDecoder
      else
        RemiLib.assert(!@decoder.nil?)
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load Vorbis #{@filename}: #{err}")
      false
    end

    def sampleRate : UInt32
      ensureFile
      @sampleRate
    end

147
148
149
150
151
152
153

154
155
156
157
158
159
160
161
152
153
154
155
156
157
158
159
160
161
162
163

164
165
166







+




-




    def rewind : Nil
      unload
      ensureFile
    end

    def unload : Nil
      Benben.dlog!("Unloading Vorbis file: #{@filename}")
      @decoder = nil
      @demuxer = nil
      @io.try(&.close)
      @io = nil
      super
    end
  end
end
Changes to src/common.cr.
76
77
78
79
80
81
82













83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111













112
113
114
115
116
117
118







+
+
+
+
+
+
+
+
+
+
+
+
+
















-
-
-
-
-
-
-
-
-
-
-
-
-







  # :nodoc:
  macro dlog!(msg)
    {% if flag?(:benben_debug) %}
      RemiLib.log.dlog!({{msg}})
      RemiLib.log.debugStream.flush
    {% end %}
  end

  ###
  ### Exceptions and Aliases
  ###

  class BenbenError < Exception
  end

  alias RAFlac = RemiAudio::Codecs::FLAC
  alias MVerb  = RemiAudio::DSP::MVerb
  alias Zita   = RemiAudio::DSP::ZitaReverb
  alias ReverbPreset = MVerb::Preset|Zita::Preset
  alias ThemeColor = UInt8|Tuple(UInt8, UInt8, UInt8)|String
end

require "./audio-formats/modulefile"
require "./audio-formats/flacfile"
require "./audio-formats/opusfile"
require "./audio-formats/vorbisfile"
require "./audio-formats/mpeg1file"
require "./audio-formats/midifile"
require "./audio-formats/vgmfile"
require "./audio-formats/pcmfile"

####
#### Common Stuff and Globals
####

module Benben
  ###
  ### Exceptions and Aliases
  ###

  class BenbenError < Exception
  end

  alias RAFlac = RemiAudio::Codecs::FLAC
  alias MVerb  = RemiAudio::DSP::MVerb
  alias Zita   = RemiAudio::DSP::ZitaReverb
  alias ReverbPreset = MVerb::Preset|Zita::Preset
  alias ThemeColor = UInt8|Tuple(UInt8, UInt8, UInt8)|String

  ###
  ### Program Constants
  ###

  SHORT_NAME = "benben"
  FANCY_NAME = "Benben Audio Player"
  VERSION = "0.6.0-dev"
Changes to src/filemanager.cr.
11
12
13
14
15
16
17
18

19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243

244
245


246
247
248
249



250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286

287
288
289
290
291
292
293
294




295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324


325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340

341
342
343
344
345
346



347
348
349
350
351
352
353
354
355
356
357
358
359

360
361


362
363

364
365


366
367
368
369
370
371
372
373
374
375
376
377
378
379
380


381
382
383
384







385
386
387
388


389
390
391
392
393
394
395










396
397
398
399
400
401
402
403



404
405
406

407

408
409
410
411
412
413
414










415
416
417
418
419
420
421
422
423










424
425
426
427
428
429



430
431
432

433
434
435
436
437









438
439
440


441
442


443

444
445
446
447
448









449
450

451
452
453
454
455
456
457


458
459
460
461
462
463
464
11
12
13
14
15
16
17

18
19
20
21
22
23
24
25
26
27
28
29
30
31





32








































































































































































































33
34
35
36
37
38

39


40
41
42



43
44
45


46
































47

48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

69
70



71
72
73
74
75
76
77
78
79
80
81
82
83



84
85










86
87
88
89
90

91
92
93
94
95


96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112


113
114
115
116
117


118
119
120
121

122
123
124
125
126
127
128
129
130
131


132
133




134
135
136
137
138
139
140
141
142


143
144







145
146
147
148
149
150
151
152
153
154
155







156
157
158


159
160

161







162
163
164
165
166
167
168
169
170
171









172
173
174
175
176
177
178
179
180
181






182
183
184



185
186




187
188
189
190
191
192
193
194
195



196
197


198
199
200
201





202
203
204
205
206
207
208
209
210


211




212

213
214
215
216
217
218
219
220
221
222







-
+













-
-
-
-
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-






-
+
-
-
+
+

-
-
-
+
+
+
-
-

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-

-
+








+
+
+
+








-


-
-
-













-
-
-
+
+
-
-
-
-
-
-
-
-
-
-





-
+




-
-
+
+
+













+
-
-
+
+


+
-
-
+
+


-










-
-
+
+
-
-
-
-
+
+
+
+
+
+
+


-
-
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+

-
-
-
-
-
-
-
+
+
+
-
-

+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
-
-
-
+

-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
-
+
+
-
-
+
+

+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
-
-
+
-
-
-
-

-

+
+







#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.
require "uri"
require "./common"
require "./loaders/*"
require "./audio-formats/playablefile"

####
#### Toplevel PlayableFile Handling
####
#### This includes the "playlist" functionality, where the program can go back
#### and forth through the list of songs.
####

module Benben
  # The `FileManager` class loads playable files from various sources into RAM,
  # then allows them to be selected in a linear way.  In other words, it acts as
  # a loader, as well as the current play queue.
  class FileManager
    # Where a `VirtualFile` comes from.
    enum Source
      # A local file.
      Local

    @files : Array(PlayableFile) = [] of PlayableFile
      # A file that is downloaded over HTTP/HTTPS.
      Http

      # A file that is downloaded over Gemini.
      Gemini
    end

    # The `VirtualFile` class is a wrapper around a `PlayableFile`, its
    # `FileType`, and its associated source filename or URL.
    private class VirtualFile
      # The loaded file.  This is always `nil` unless `load` is called.
      property! file : PlayableFile?

      property type : FileType = FileType::Unknown

      # The filename..
      getter filename : String

      # Where the `VirtualFile` originates.
      getter source : Source

      # The URL where the `VirtualFile` originates.  This may be `nil` if it
      # originates from a local file.
      property! url : URI?

      getter timeLength : Time::Span = Time::Span.new

      # Creates a new `VirtualFile` instance.
      def initialize(@filename : String, @source : Source, @url : URI? = nil)
      end

      # Returns `true` if this `VirtualFile` can be cached in RAM, or `false`
      # otherwise.  All non-local files can be cached.
      def cacheable? : Bool
        case @source
        when .http?, .gemini? then true
        else false
        end
      end

      # Calls the actual file loading routine without caching anything.
      private def doLoad(loader : FileManager) : Tuple(PlayableFile?, FileType)
        case @source
        in .local? then loader.loadNormalFile(@filename, @type)
        in .http? then loader.loadHTTPFile(self.url)
        in .gemini? then loader.loadGeminiFile(self.url)
        end
      end

      # Loads the file into RAM if it isn't already presessnt in memory, then
      # returns it.  This may return `nil` if the file was not in RAM yet and
      # could not be loaded.
      def load(loader : FileManager) : Tuple(PlayableFile?, FileType)
        Yuno.dlog!("Loading playable file into RAM")
        if ret = @file
          Yuno.dlog!("Playable file already in RAM")
          {ret, @type}
        else
          # File is not yet in memory.
          if cacheable?
            # Save the results in RAM.
            @file, @type = doLoad(loader)
            {self.file, @type}
          else
            doLoad(loader)
          end
        end
      end

      # Checks to see if this `VirtualFile` represents a valid file.  If it
      # does, this returns `true`, otherwise it returns `false`.  This will also
      # cache the file into RAM if it's `#cacheable?`, and determine the total
      # time of the track.
      def cacheAndValidate(loader : FileManager) : Bool
        Yuno.dlog!("Validating virtual file - cacheable: #{cacheable?}, file in ram: #{!@file.nil?}")

        ret = if cacheable? && @file.nil?
                # Not in memory yet, so temporarily read it into RAM.
                begin
                  !self.load(loader)[0].nil?
                rescue Exception
                  false
                end
              elsif @source.local?
                begin
                  @type = FileManager.getFileType(@filename)
                  !@type.unknown?
                rescue err : Exception
                  Benben.dlog!("Exception in cacheAndValidate while validating file: #{err}")
                  Benben.dlog!("Backtrace of error: #{err.backtrace}")
                  false
                end
              else
                !@file.nil?
              end

        calcTimeLength(loader) if ret
        ret
      end

      # Calculates the total time length of the track, storing the value in the
      # `@timeLength` field.  The file data is unloaded before this returns.
      #
      # The calculation takes into account the number of loops, and thus
      # temporarily loads any applicable song config into RAM.  This song config
      # is **NOT** applied to the global `EphemeralConfig` singleton
      # (`Benben.config`).
      #
      # This is not thread safe.
      private def calcTimeLength(loader : FileManager) : Nil
        songConf = Benben.config.getSongConfig(self.filename)
        loops = if songConf && songConf.maxLoops_present?
                  songConf.maxLoops
                else
                  Benben.config.maxLoopsAll[0]
                end

        if data = self.load(loader)[0]
          @timeLength = ((data.totalSamples * Math.max(1, loops)) / data.sampleRate).seconds
          data.unload
        end
      end
    end

    private def self.getFileTypeGuess(filename : Path|String) : FileType?
      # Try to use the extension as an educated guess to speed this up.
      ext = case filename
            in Path then filename.extension
            in String then Path[filename].extension
            end
      case ext.downcase
      when ".vgzst", ".vgm", ".vgb", ".vgz"
        FileType::Vgm if VgmFile.validVgmFile?(filename)
      when ".ogg", ".oga"
        FileType::Vorbis if VorbisFile.test(filename)
      when ".opus"
        FileType::Opus if OpusFile.test(filename)
      when ".flac"
        FileType::Flac if FlacFile.test(filename)
      when ".mp3", ".mp2", ".mp1"
        FileType::Mpeg1 if Mpeg1File.test(filename)
      when ".wav", ".wave", ".au"
        FileType::Pcm if PcmFile.test(filename)
      when ".midi", ".mid", ".mus", ".rmi"
        FileType::Midi if Haematite::SequencedFile.validFile?(filename)
      else nil # Using the extension didn't yield a result
      end
    rescue Exception
      nil
    end

    # Determines if **filename** is a file type that is supported by Benben, and
    # if it is a supported filename, is also a supported file.  This returns the
    # `FileType`, which will be `FileType::Unknown` if it's unsupported.
    def self.getFileType(filename : Path|String) : FileType
      ret : FileType = FileType::Unknown

      # Easy out
      return ret if !File.exists?(filename) || !File.readable?(filename)

      # Try to use a faster method first
      if luckyGuess = getFileTypeGuess(filename)
        return luckyGuess
      end

      # If we've made it here, then the filename extension didn't help us.  Do
      # more intensive checks.
      case
      when VgmFile.validVgmFile?(filename)
        ret = FileType::Vgm
      when Haematite::SequencedFile.validFile?(filename)
        ret = FileType::Midi
      when RemiXmp.test(filename)
        ret = FileType::Module
      when VorbisFile.test(filename)
        ret = FileType::Vorbis
      when OpusFile.test(filename)
        ret = FileType::Opus
      when FlacFile.test(filename)
        ret = FileType::Flac
      when Mpeg1File.test(filename) # This one can sometimes give false
                                    # positives for module files, so do it
                                    # *AFTER* module testing
        ret = FileType::Mpeg1
      when Haematite::SequencedFile.validFile?(filename)
        ret = FileType::Midi
      when PcmFile.test(filename)
        ret = FileType::Pcm
      else
        Benben.dlog!("Couldn't determine filetype for loading: #{filename}")
      end

      Benben.dlog!("File type #{ret}: #{filename}") unless ret.unknown?
      ret
    rescue err : Exception
      Benben.dlog!("Could not determine file type due to an exception: #{err} (backtrace: #{err.backtrace}")
      FileType::Unknown
    end

    @files : Array(VirtualFile) = [] of VirtualFile
    protected getter currentTrack : Atomic(Int32) = Atomic(Int32).new(-1)
    @errors : Array(String) = [] of String
    @repeatList : Bool = false
    @lastSpeedUpdate : Time::Span = Time.monotonic
    @totalTime : Time::Span? = nil

    # Creates a new `FileManager` instance.  This will pre-load/cache all
    # Creates a new `FileManager` instance.  This will check for bad files and
    # PlayableFile instances that need caching, while also checking for bad
    # files and printing the results as-needed.
    # printing the results as-needed, as well as create the virtual play queue
    # based on the command line arguments.
    def initialize
      Yuno.dlog!("File handler initializing")
      # Loop through each positional argument and attempt to load each one.
      Benben.args.positionalArgs.each do |arg|
      Benben.dlog!("File handler initializing")
      # Attempt to load each positional argument as a file or directory.
      Benben.args.positionalArgs.each { |arg| addFile(arg) }
        addFile(arg)
      end

      # Remote files are cached in memory, so do that now.  Also remove nils.
      Yuno.dlog!("Validating files")
      if @files.size < 4
        @files.reject! do |file|
          ret = !file.cacheAndValidate(self)
          RemiLib.log.error("Cannot play file: #{file.filename}") if ret
          ret
        end
      else
        # Use a JobPool so we can check multiple files at once.  We'll store
        # stuff in a channel to skip a mutex (at least I think Channels are
        # lockless internally?)
        toReject = Channel(VirtualFile).new(@files.size + 1)
        pool = RemiLib::JobPool(VirtualFile).new((System.cpu_count.to_s.to_i32? || 6) + 2)
        pool.run(@files) do |file|
          invalid = !file.cacheAndValidate(self)
          if invalid
            RemiLib.log.error("Cannot play file: #{file.filename}")
            toReject.send(file)
          end
        end

        # Now do the actual removal.
        loop do
          select
          when file = toReject.receive
            @files.delete(file)
          else break
          end
        end
      end

      # Are we playing MIDI files?  Ensure we have a SoundFont.
      if @files.any?(&.type.midi?)
      if @files.any?(&.is_a?(MidiFile))
        if Benben.config.midi.soundfont.empty?
          raise ConfigError.new("You have MIDI files to play, but have no SoundFont specified in your config file")
        elsif !File.exists?(Benben.config.midi.soundfont)
          raise ConfigError.new("You have MIDI files to play, but the specified SoundFont does not exist")
        elsif !File.readable?(Benben.config.midi.soundfont)
          raise ConfigError.new("You have MIDI files to play, but the specified SoundFont cannot be read")
        end
      end

      ##
      ## Handle other command-line arguments
      ##

      @repeatList = if Benben.args["no-repeat"].called?
                      false
                    else
                      Benben.args["repeat"].called? || Benben.config.repeatList?
                    end

      if Benben.args["shuffle"].called? && !Benben.args["render"].called?
        # Shuffle the playlist
        @files.shuffle!
      end

      finishPrinting
      printErrors
    end

    def self.getMagic(filename : String, count = 4) : Bytes
      ret = Bytes.new(count)
      File.open(filename, "rb") do |f|
        numRead = f.read(ret)
        if numRead < count
          return ret[..numRead]
        end
      end
      ret
    end

    # Adds a local file from `filename`.
    private def addLocal(filename : String) : Nil
      @files << VirtualFile.new(filename, Source::Local)
    private def addLocal(file : PlayableFile) : Nil
      @files << file
    end

    # Adds a file from `uri`.
    private def addHttp(uri : URI) : Nil
      @files << VirtualFile.new(uri.to_s, Source::Http, uri)
    end

    # :ditto:
    private def addGemini(uri : URI) : Nil
      @files << VirtualFile.new(uri.to_s, Source::Gemini, uri)
    end

    private def addFile(arg : String) : Nil
      case
      when File.exists?(arg) && !Dir.exists?(arg)
        Yuno.dlog!("Adding local file: #{arg}")
        Benben.dlog!("Adding local file: #{arg}")
        if arg.downcase.ends_with?(".xspf")
          loadXspfFile(Path[arg])
        elsif arg.downcase.ends_with?(".jspf")
          loadJspfFile(Path[arg])
        else
          addLocal(arg)
        elsif newFile = PlayableFile.create(arg)
          newFile.unload
          addLocal(newFile)
        end

      when Dir.exists?(arg)
        # Sort the children since the filesystem may not report the files in
        # sorted order, but rather in the order they were inserted into the
        # filesystem (which may not be in-order).
        Dir.children(arg).sort.each do |child|
          childPath = Path[arg, child].to_s
          # Only add the child if it's a file.
          addFile(childPath) if File.exists?(childPath) && !Dir.exists?(childPath)
        end

      when arg.starts_with?("https://") || arg.starts_with?("http://")
        RemiLib.log.warn("HTTP/HTTPS no longer supported: #{arg}")
        Yuno.dlog!("Downloading from an HTTP/HTTPS URL: #{arg}")
        addHttp(URI.parse(arg))
        # Benben.dlog!("Downloading from an HTTP/HTTPS URL: #{arg}")
        #addHttp(URI.parse(arg))

      when arg.starts_with?("gemini://")
        RemiLib.log.warn("Gemini no longer supported: #{arg}")
        Yuno.dlog!("Downloading from a Gemini URL: #{arg}")
        addGemini(URI.parse(arg))
        # Benben.dlog!("Downloading from a Gemini URL: #{arg}")
        # addGemini(URI.parse(arg))

      else
        Yuno.dlog!("Can't add file: #{arg}")
        # arg doesn't hold an existing filename, and it isn't a link.  It may
        # be a directory, or simply a file that does't exist.
        if Dir.exists?(arg)
          RemiLib.log.error("Cannot load file: #{arg} is a directory, not a file")
        else
          RemiLib.log.error("Cannot load file: #{arg} does not exist")
        end
      end
    end

    ############################################################################

    # Loads a playlist in XSPF format.  Returns `true` if at least one track
    # from the playlist was loaded, or `false` otherwise.
     @[AlwaysInline]
    private def clearLine : Nil
      RemiLib::Console.cursor(STDOUT, 1)
      RemiLib::Console.erase(STDOUT)
    protected def loadXspfFile(playlistFile : Path) : Bool
      RemiLib.log.log("Parsing #{playlistFile}")
      playlist = File.open(playlistFile, "r") { |file| RemiXspf::Playlist.read(file) }
      parseXspf(playlist)
    rescue err : Exception
      RemiLib.log.error("Cannot load playlist file #{playlistFile}: #{err}")
      false
    end

    # Prints out the current file being processed.  `label` is the label to
    # describe what's being done (downloading, loading, manifesting from the
    # Loads a playlist in JSPF format.  Returns `true` if at least one track
    # from the playlist was loaded, or `false` otherwise.
    # aether, etc).
    private def printCurrentFile(filename : String, label : String) : Nil
      maxLength = 80 - label.size - 6

      clearLine
      STDOUT << label << ": " << Utils.sanitizeStr(Utils.clipField(filename, maxLength))
    end
    protected def loadJspfFile(playlistFile : Path) : Bool
      RemiLib.log.log("Parsing #{playlistFile}")
      playlist = File.open(playlistFile, "r") do |file|
        RemiXspf::Playlist.read(file, format: RemiXspf::Playlist::Format::Json)
      end
      parseXspf(playlist)
    rescue err : Exception
      RemiLib.log.error("Cannot load playlist file #{playlistFile}: #{err}")
      false
    end

    # Prints out the current file being processed.  `label` is the label to
    # describe what's being done (downloading, loading, manifesting from the
    # aether, etc), `amount` is the amount downloaded, and `total` is the total
    # to download.  `avg` is the average speed in bytes.
    private def printCurrentFile(filename : String, label : String, amount : Int32, total : Int32, avg : Int32) : Nil
      maxLength = 80 - label.size - 6
      avgStr = "#{avg.prettySize}/s"
    private def parseXspf(playlist : RemiXspf::Playlist) : Bool
      loaded : Bool = false
      numLoaded = 0
      extra = " #{amount.prettySize} / #{total.prettySize} #{((amount / total) * 100).to_i32!}% #{avgStr}"
      maxLength -= extra.size

      playlist.tracks.each_with_index do |track, idx|
      clearLine
        track.locations.each do |loc|
      STDOUT << label << ": " << Utils.sanitizeStr(Utils.clipField(filename, maxLength)) << extra
    end

    # Prints out the current file being processed.  `label` is the label to
    # describe what's being done (downloading, loading, manifesting from the
    # aether, etc).  If `amount` is provided, then that is printed as pretty
    # bytes.
          loaded = case loc.scheme
                   when "gemini"
                     RemiLib.log.warn("Gemini no longer supported: #{loc}")
                     false
                     # addGemini(loc)
                     # true
                   when "http", "https"
                     RemiLib.log.warn("HTTP/HTTPS no longer supported: #{loc}")
                     false
                     # addHttp(loc)
    private def printCurrentFile(filename : String, label : String, amount : Int32) : Nil
      maxLength = 80 - label.size - 6
      extra = " #{amount.prettySize}"
      maxLength -= extra.size

      clearLine
      STDOUT << label << ": " << Utils.sanitizeStr(Utils.clipField(filename, maxLength)) << extra
    end

                     # true
                   when "file"
                     if newFile = PlayableFile.create(URI.decode(loc.path))
                       newFile.unload
                       addLocal(newFile)
                       true
                     else
                       false
                     end
                   else
    # Prints out the current file being processed.  `label` is the label to
    # describe what's being done (downloading, loading, manifesting from the
    # aether, etc).  If `amount` is provided, then that is printed as pretty
    # bytes.  `avg` is the average speed in bytes.
    private def printCurrentFile(filename : String, label : String, amount : Int32, avg : Int32) : Nil
      maxLength = 80 - label.size - 6
                     false
                   end
          break if loaded
      avgStr = "#{avg.prettySize}/s"
      extra = " #{amount.prettySize} #{avgStr}"
      maxLength -= extra.size
        end

      clearLine
      STDOUT << label << ": " << Utils.sanitizeStr(Utils.clipField(filename, maxLength)) << extra
    end

        unless loaded
          # Try to get some sort of identifier for the track.
          str : String = if track.title
                           track.title.not_nil!
                         elsif track.trackNumber
                           "track #{track.trackNumber.not_nil!}"
                         elsif track.locations.size == 1
                           track.locations[0].to_s
                         elsif track.identifiers.size == 1
    # Cleans up the output once printing is done.
    private def finishPrinting : Nil
      clearLine
                           track.identifiers[0].to_s
                         else
      STDOUT.flush
    end
                           ""
                         end

          # If we have a way of identifying the track, do so in the error
    # Logs an error message that will be displayed after loading is finished.
    private def logError(string : String) : Nil
      @errors << string
    end

          # message.
          unless str.empty?
            RemiLib.log.error("Don't know how to load track from playlist: #{str}")
          else
            # No way of identifying it, so just list its position in the
            # playlist.
            RemiLib.log.error("Don't know how to load track at position #{idx + 1} in the playlist")
          end
        else
    # Prints all logged error messages, then clears them.
    private def printErrors : Nil
          numLoaded += 1
      unless @errors.empty?
        STDERR.flush
        @errors.each do |err|
          RemiLib.log.error(err)
        end
        @errors.clear # No need to keep them around.
      end

      numLoaded != 0
    end

    ############################################################################

    # Returns the number of files that this `FileManager` knows about.
    def size
      @files.size
474
475
476
477
478
479
480
481

482
483
484
485
486
487
488
489

490
491
492
493
494
495
496

497
498
499
500
501


502
503
504

505
506
507
508
509
510
511
512
513
514
515
516
517
518
519





520
521
522
523
524
525
526
527
528
529

530
531
532
533
534
535
536
537

538
539
540
541
542
543
544
545

546
547
548
549
550
551
552
553
554
555
556
557
558
559
560



561
562
563
564
565
566
567
568

569
570
571
572
573
574
575
576
577
578
232
233
234
235
236
237
238

239
240
241
242
243
244
245
246

247
248
249
250
251
252
253

254
255
256
257


258
259

260

261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290

291
292
293
294
295
296
297
298

299
300
301
302
303
304
305
306

307
308
309
310
311
312
313
314
315
316
317
318
319
320


321
322
323
324
325
326
327
328
329
330

331
332


333
334
335
336
337
338
339







-
+







-
+






-
+



-
-
+
+
-

-
+















+
+
+
+
+









-
+







-
+







-
+













-
-
+
+
+







-
+

-
-







    #
    # If the queue is set to repeat, and the `FileManager` is currently at the
    # end of the queue, then it loops back and returns the first entry.
    # Otherwise, if it is not set to repeat and it's at the end of the queue,
    # this returns `nil`.
    #
    # This may load a file if it is not already in memory.
    def next : Tuple(PlayableFile?, FileType)
    def next : PlayableFile?
      origStart = @currentTrack.lazy_get

      loop do
        @currentTrack.add(1)

        if @currentTrack.lazy_get == origStart
          RemiLib.log.error("Reached the original position with no files loadable")
          return {nil, FileType::Unknown}
          return nil
        end

        if @currentTrack.lazy_get >= @files.size
          if @repeatList && !Benben.args["render"].called?
            @currentTrack.set(0)
          else
            return {nil, FileType::Unknown}
            return nil
          end
        end

        file, typ = @files[@currentTrack.lazy_get].load(self)
        if file.nil? || typ.unknown?
        if (file = @files[@currentTrack.lazy_get]).ensureFile
          return file
          Benben.ui.queueError("Can't load file: #{currentFileBasename}")
        else
          return {file, typ}
          Benben.ui.queueError("Can't load file: #{currentFileBasename}")
        end
      end
    end

    def get(index : Int) : VirtualFile
      @files[index]
    end

    def getFilename(index : Int) : String
      @files[index].filename
    end

    def getFileBasename(index : Int) : String
      Path[@files[index].filename].basename
    end

    # Unloads the current file from RAM.
    def unloadCurrent : Nil
      @files[@currentTrack.lazy_get].unload
    end

    # Returns the previous PlayableFile in the queue and its type.
    #
    # If the queue is set to repeat, and the `FileManager` is currently at the
    # beginning of the queue, then it loops to the end and returns the last
    # entry.  Otherwise, if it is not set to repeat and it's at the beginning of
    # the queue, this repeatedly returns the first entry.
    #
    # This may load a file if it is not already in memory.
    def prev : Tuple(PlayableFile?, FileType)
    def prev : PlayableFile?
      origStart = @currentTrack.lazy_get

      loop do
        @currentTrack.sub(1)

        if @currentTrack.lazy_get == origStart
          RemiLib.log.error("Reached the original position with no files loadable")
          return {nil, FileType::Unknown}
          return nil
        end

        if @currentTrack.lazy_get < 0
          # Too far back
          if @files.empty?
            # ... and no files
            @currentTrack.set(-1)
            return {nil, FileType::Unknown}
            return nil
          end

          # No need to check for --render here since calling #prev isn't possible
          # when rendering.
          if @repeatList
            # Go to the last file
            @currentTrack.set(@files.size - 1)
          else
            # Force the first file
            @currentTrack.set(0)
          end
        end

        file, typ = @files[@currentTrack.lazy_get].load(self)
        if file.nil? || typ.unknown?
        if (file = @files[@currentTrack.lazy_get]).ensureFile
          return file
        else
          Benben.ui.queueError("Can't load file: #{currentFileBasename}")
          if @currentTrack.lazy_get == 0
            # The first file is something that can't be loaded, so just return
            # nil.
            #
            # TODO Might be better to handle things like @repeatList or moving
            # to the following file here.
            return {nil, FileType::Unknown}
            return nil
          end
        else
          return {file, typ}
        end
      end
    end

    # Returns the filename associated with the current PlayableFile.
    def currentFilename : String
      if @currentTrack.lazy_get >= @files.size || @currentTrack.lazy_get < 0
Deleted src/loaders/gemini.cr.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122


























































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
#### Benben
#### Copyright (C) 2023-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or
#### modify it under the terms of the GNU Affero General Public
#### License as published by the Free Software Foundation, either
#### version 3 of the License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.

####
#### Gemini Protocol Loader
####

module Benben
  class FileManager
    # Attempts to download a VGM file from `url` over the Gemini protocol.
    # Returns a `VgmFile` on success, or `nil` otherwise.
    #
    # https://en.wikipedia.org/wiki/Gemini_(protocol)
    protected def loadGeminiFile(url : URI) : Tuple(PlayableFile?, FileType)
      ret : VgmFile? = nil
      retType = FileType::Unknown
      printCurrentFile(url.to_s, "Download")

      # Get the hostname and port from the URL.  If the port isn't provided, use
      # the default Gemini port (1965).  A host must be provided.
      host = url.host
      port = url.port || 1965
      unless host
        logError("No host found in gemini:// URL")
        return {nil, FileType::Unknown}
      end

      begin
        # Create our TCP socket
        TCPSocket.open(host, port) do |tcpSock|
          # Gemini servers usually use self-signed certificates.
          ctx = OpenSSL::SSL::Context::Client.new
          ctx.add_options(OpenSSL::SSL::Options::NO_SSL_V2 |
                          OpenSSL::SSL::Options::NO_SSL_V3 |
                          OpenSSL::SSL::Options::NO_TLS_V1 |
                          OpenSSL::SSL::Options::NO_TLS_V1_1 |
                          OpenSSL::SSL::Options::ALL)
          ctx.verify_mode = if Benben.args["gemini-no-cert-verify"].called?
                              OpenSSL::SSL::VerifyMode::NONE
                            else
                              OpenSSL::SSL::VerifyMode::PEER
                            end

          # Open the TLS connection.
          OpenSSL::SSL::Socket::Client.open(tcpSock, ctx) do |sock|
            # Send the request
            sock << url.to_s << "\r\n"
            sock.flush

            # Get the response
            resp = sock.gets("\r\n", true)
            unless resp
              logError("Cannot load audio file from URL (no response): #{url}")
              return {nil, FileType::Unknown}
            end

            # Parse out the status code and the content type.
            if info = /([0-9][0-9]) (.+)/.match(resp)
              # We only accept status code 20 (success) for now.
              if info[1] == "20"
                # We'll use a buffer for the rest so that we can print the
                # progress.
                buf : Bytes = Bytes.new(65536)
                totalRead : Int32 = 32

                # Recreate the IO::Memory, then add the magic bytes back in
                # since we've already read them.
                data : IO::Memory = IO::Memory.new

                # Gemini doesn't provide a way to know the content length in
                # advance, so we download data until #read returns zero.
                Yuno.dlog!("Gemini: Reading data")
                while (numRead = sock.read(buf)) > 0
                  data.write(buf[0...numRead])
                  totalRead += numRead
                  printCurrentFile(url.to_s, "Download", totalRead)
                end
                Yuno.dlog!("Gemini: Data read #{data.size}")

                # Be kind, rewind, then load the file from the in-memory stream.
                data.rewind
                begin
                  ret = VgmFile.new(url.to_s, data)
                  retType = FileType::Vgm
                rescue err : Yuno::YunoError
                  logError("Cannot load audio file from URL: #{err}")
                rescue err : Exception
                  logError("Problem loading audio file from URL: #{err}")
                end
              else
                logError("Cannot retrieve audio (response code #{info[1]}): #{url}")
              end
            else
              logError("Cannot retrieve audio (invalid Gemini response): #{url}")
            end
          end
        end
      rescue err : OpenSSL::SSL::Error
        logError("Cannot retrieve audio (TLS error): #{err}")
      rescue err : Socket::Error
        logError("Cannot retrieve audio (socket error): #{err}")
      rescue err : Exception
        logError("Cannot retrieve audio: #{err}")
      end

      {ret, retType}
    end
  end
end
Deleted src/loaders/http.cr.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124




























































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
#### Benben
#### Copyright (C) 2023-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or
#### modify it under the terms of the GNU Affero General Public
#### License as published by the Free Software Foundation, either
#### version 3 of the License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.
require "http/client"

####
#### HTTP/HTTPS Loader
####

module Benben
  class FileManager
    # Attempts to download a file from `url` over HTTP.  Returns a
    # `Tuple(PlayableFile?, FileType)` on success, or `Tuple(nil, FileType::Unknown)` otherwise.
    protected def loadHTTPFile(url : URI) : Tuple(PlayableFile?, FileType)
      printCurrentFile(url.to_s, "Download")
      @lastSpeedUpdate = Time.monotonic

      loop do
        # Make the GET request
        Yuno.dlog!("HTTP: Performing GET")
        HTTP::Client.get(url) do |resp|
          # Check for redirect
          if resp.status.redirection?
            logError("Redirect")
            if location = resp.headers["location"]?
              begin
                url = URI.parse(location)
              rescue err : Exception
                logError("Redirect received, but the new location was a bad URL: #{Utils.getShortenedUrl(url, 30)}")
                return {nil, FileType::Unknown}
              end
            else
              logError("Redirect received, but no location was found: #{Utils.getShortenedUrl(url, 30)}")
              return {nil, FileType::Unknown}
            end

            # Break out of the HTTP::Client.get so that the outer until loop can
            # run again.
            break
          end

          # We only accept status 200 for now.
          if resp.status_code == 200
            # We'll use a buffer so that we can print a percentage.
            buf = Bytes.new(65535)
            totalRead = 0
            len = 0

            # If the headers have a Content-Length, then we can provide a
            # percentage.
            if resp.headers["Content-Length"]?
              len = resp.headers["Content-Length"].to_i32
            end

            # An in-memory buffer we'll store the data in temporarily.
            data = IO::Memory.new(len.nil? ? 65536 : len)

            # Used to hold the average speed
            avg = 0
            bytesLastPeriod = 0

            # Start reading the data from the HTTP client response.
            while (pos = resp.body_io.read(buf)) > 0
              if (Time.monotonic - @lastSpeedUpdate).total_seconds >= 1
                avg = (bytesLastPeriod / ((Time.monotonic - @lastSpeedUpdate).total_nanoseconds / 1000000000)).to_i32!
                bytesLastPeriod = 0
                @lastSpeedUpdate = Time.monotonic
              end
              data.write(buf[0...pos])
              bytesLastPeriod += pos

              # Print the progress
              if len > 0
                totalRead += pos
                printCurrentFile(url.to_s, "Download", totalRead, len, avg)
              else
                totalRead += pos
                printCurrentFile(url.to_s, "Download", totalRead, avg)
              end
            end
            Yuno.dlog!("HTTP: Data read")

            # Rewind, then load the file from the in-memory stream.
            data.rewind
            begin
              # Download succeeded, return the new VgmFile instance.
              ret = VgmFile.new(url.to_s, data)
              return {ret, FileType::Vgm}
            rescue err : Yuno::YunoError
              logError("Cannot load audio file from URL #{Utils.getShortenedUrl(url, 30)}: #{err}")
              return {nil, FileType::Unknown}
            rescue err : Exception
              logError("Problem loading audio file from URL #{Utils.getShortenedUrl(url, 30)}: #{err}")
              return {nil, FileType::Unknown}
            end
          else
            logError("Cannot retrieve audio (HTTP code #{resp.status_code}): #{Utils.getShortenedUrl(url, 30)}")
            return {nil, FileType::Unknown}
          end
        end
      rescue err : OpenSSL::SSL::Error
        logError("Cannot retrieve audio (TLS error) from #{Utils.getShortenedUrl(url, 30)}: #{err}")
      rescue err : Socket::Error
        logError("Cannot retrieve audio (socket error) from #{Utils.getShortenedUrl(url, 30)}: #{err}")
      rescue err : Exception
        logError("Cannot retrieve audio: #{err}")
      end

      {nil, FileType::Unknown}
    end
  end
end
Deleted src/loaders/normal.cr.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61





























































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
#### Benben
#### Copyright (C) 2023-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or
#### modify it under the terms of the GNU Affero General Public
#### License as published by the Free Software Foundation, either
#### version 3 of the License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.

####
#### Local File Loader
####

module Benben
  class FileManager
    # Attempts to load an audio file from disk.  Returns a `Tuple(PlayableFile?,
    # FileType)` where the first element is non-`nil` on success, or `nil`
    # otherwise.
    protected def loadNormalFile(filename : String, typ : FileType) : Tuple(PlayableFile?, FileType)
      begin
        case typ
        in .vgm?
          return {VgmFile.new(filename), typ}
        in .module?
          return {ModuleFile.new(filename), typ}
        in .flac?
          return {FlacFile.new(filename), typ}
        in .opus?
          return {OpusFile.new(filename), typ}
        in .vorbis?
          return {VorbisFile.new(filename), typ}
        in .mpeg1?
          return {Mpeg1File.new(filename), typ}
        in .midi?
          return {MidiFile.new(filename), typ}
        in .pcm?
          return {PcmFile.new(filename), typ}
        in .unknown?
          logError("Cannot play file: #{filename}")
        end
      rescue err : Yuno::YunoError
        Benben.dlog!("YunoSynth Error loading file: #{err} (#{err.backtrace}")
        RemiLib.log.error("Problem loading file: #{err}")
        logError("Cannot load file: #{err}")
      rescue err : Exception
        Benben.dlog!("Error loading file: #{err} (#{err.backtrace}")
        RemiLib.log.error("Problem loading file: #{err}")
        logError("Problem loading file: #{err}")
      end
      logError("Cannot load unsupported file")
      {nil, FileType::Unknown}
    end
  end
end
Deleted src/loaders/xspf.cr.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109













































































































-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
#### Benben
#### Copyright (C) 2023-2024 Remilia Scarlet <remilia@posteo.jp>
####
#### This program is free software: you can redistribute it and/or
#### modify it under the terms of the GNU Affero General Public
#### License as published by the Free Software Foundation, either
#### version 3 of the License, or (at your option) any later version.
####
#### This program is distributed in the hope that it will be useful,
#### but WITHOUT ANY WARRANTY; without even the implied warranty of
#### MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#### Affero General Public License for more details.
####
#### You should have received a copy of the GNU Affero General Public License
#### along with this program.  If not, see <https://www.gnu.org/licenses/>.
require "remixspf"

####
#### XSPF/JSPF Playlist Loader
####
#### This is a special case that just calls the other loaders as-needed.
####

module Benben
  class FileManager
    # Loads a playlist in XSPF format.  Returns `true` if at least one track
    # from the playlist was loaded, or `false` otherwise.
    protected def loadXspfFile(playlistFile : Path) : Bool
      printCurrentFile(playlistFile.to_s, "Parsing")

      begin
        playlist = File.open(playlistFile, "r") { |file| RemiXspf::Playlist.read(file) }
        parseXspf(playlist)
      rescue err : Exception
        logError("Cannot load playlist file #{playlistFile}: #{err}")
        false
      end
    end

    # Loads a playlist in JSPF format.  Returns `true` if at least one track
    # from the playlist was loaded, or `false` otherwise.
    protected def loadJspfFile(playlistFile : Path) : Bool
      printCurrentFile(playlistFile.to_s, "Parsing")

      begin
        playlist = File.open(playlistFile, "r") do |file|
          RemiXspf::Playlist.read(file, format: RemiXspf::Playlist::Format::Json)
        end
        parseXspf(playlist)
      rescue err : Exception
        logError("Cannot load playlist file #{playlistFile}: #{err}")
        false
      end
    end

    private def parseXspf(playlist : RemiXspf::Playlist) : Bool
      loaded : Bool = false
      numLoaded = 0

      playlist.tracks.each_with_index do |track, idx|
        track.locations.each do |loc|
          loaded = case loc.scheme
                   when "gemini"
                     addGemini(loc)
                     true
                   when "http", "https"
                     addHttp(loc)
                     true
                   when "file"
                     addLocal(URI.decode(loc.path))
                     true
                   else
                     false
                   end
          break if loaded
        end

        unless loaded
          # Try to get some sort of identifier for the track.
          str : String = if track.title
                           track.title.not_nil!
                         elsif track.trackNumber
                           "track #{track.trackNumber.not_nil!}"
                         elsif track.locations.size == 1
                           track.locations[0].to_s
                         elsif track.identifiers.size == 1
                           track.identifiers[0].to_s
                         else
                           ""
                         end

          # If we have a way of identifying the track, do so in the error
          # message.
          unless str.empty?
            logError("Don't know how to load track from playlist: #{str}")
          else
            # No way of identifying it, so just list its position in the
            # playlist.
            logError("Don't know how to load track at position #{idx + 1} in the playlist")
          end
        else
          numLoaded += 1
        end
      end

      numLoaded != 0
    end
  end
end
Changes to src/playermanager.cr.
51
52
53
54
55
56
57
58
59
60



61
62
63
64
65
66

67
68
69
70
71
72

73
74
75
76
77
78

79
80
81
82
83
84

85
86
87
88
89
90

91
92
93
94
95
96

97
98
99
100
101
102

103
104
105
106
107
108

109
110
111
112
113

114
115
116
117
118
119
120
121
122
123
124
125

126
127
128
129
130
131
132
133

134
135
136
137
138
139
140
51
52
53
54
55
56
57



58
59
60
61
62
63
64
65

66
67
68
69
70
71

72
73
74
75
76
77

78
79
80
81
82
83

84
85
86
87
88
89

90
91
92
93
94
95

96
97
98
99
100
101

102
103
104
105
106
107

108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125

126
127
128
129
130
131
132
133

134
135
136
137
138
139
140
141







-
-
-
+
+
+





-
+





-
+





-
+





-
+





-
+





-
+





-
+





-
+





+











-
+







-
+







    delegate loopIndefinitely, to: @player
    delegate currentLoop, to: @player
    delegate infoLine, to: @player

    def initialize
    end

    def play(file : PlayableFile, typ : FileType) : Nil
      case typ
      in .vgm?
    def play(file : PlayableFile) : Nil
      case file
      in VgmFile
        Benben.dlog!("PlayerManager: Playing a VGM file")
        @player = VgmPlayer.new unless @player.is_a?(VgmPlayer)
        raise "Expected a VGM file, but got a #{file.class}" unless file.is_a?(VgmFile)
        @player.play(file)

      in .module?
      in ModuleFile
        Benben.dlog!("PlayerManager: Playing a module file")
        @player = ModulePlayer.new unless @player.is_a?(ModulePlayer)
        raise "Expected a module file, but got a #{file.class}" unless file.is_a?(ModuleFile)
        @player.play(file)

      in .flac?
      in FlacFile
        Benben.dlog!("PlayerManager: Playing a FLAC file")
        @player = FlacPlayer.new unless @player.is_a?(FlacPlayer)
        raise "Expected a FLAC file, but got a #{file.class}" unless file.is_a?(FlacFile)
        @player.play(file)

      in .opus?
      in OpusFile
        Benben.dlog!("PlayerManager: Playing an Opus file")
        @player = OpusPlayer.new unless @player.is_a?(OpusPlayer)
        raise "Expected an Opus file, but got a #{file.class}" unless file.is_a?(OpusFile)
        @player.play(file)

      in .vorbis?
      in VorbisFile
        Benben.dlog!("PlayerManager: Playing an Ogg Vorbis file")
        @player = VorbisPlayer.new unless @player.is_a?(VorbisPlayer)
        raise "Expected an Ogg Vorbis file, but got a #{file.class}" unless file.is_a?(VorbisFile)
        @player.play(file)

      in .mpeg1?
      in Mpeg1File
        Benben.dlog!("PlayerManager: Playing an MPEG-1 file")
        @player = Mpeg1Player.new unless @player.is_a?(Mpeg1Player)
        raise "Expected an MPEG-1 file, but got a #{file.class}" unless file.is_a?(Mpeg1File)
        @player.play(file)

      in .midi?
      in MidiFile
        Benben.dlog!("PlayerManager: Playing a MIDI file")
        @player = MidiPlayer.new unless @player.is_a?(MidiPlayer)
        raise "Expected a MIDI file, but got a #{file.class}" unless file.is_a?(MidiFile)
        @player.play(file)

      in .pcm?
      in PcmFile
        Benben.dlog!("PlayerManager: Playing a PCM file")
        @player = PcmPlayer.new unless @player.is_a?(PcmPlayer)
        raise "Expected a PCM file, but got a #{file.class}" unless file.is_a?(PcmFile)
        @player.play(file)

      in .unknown?
      in PlayableFile
        raise "Cannot play an unknown file type"
      end
    end

    def getNextFile
      Benben.fileHandler.unloadCurrent
      if @goBackOneFile
        @goBackOneFile = false
        Benben.fileHandler.prev
      else
        Benben.fileHandler.next
      end
    end

    def start
      until @earlyExit
        Benben.dlog!("PlayerManager: Getting a new file")
        nextFile, typ = getNextFile
        nextFile = getNextFile
        unless nextFile
          Benben.dlog!("PlayerManager: nextFile is nil, breaking")
          break
        end
        Benben.ui.initialUpdateFinished.set(false)

        Benben.config.withSongConfig(Benben.fileHandler.currentFilename) do
          play(nextFile, typ)
          play(nextFile)

          # Flush input
          Benben.manager.fromPlayer.send(ManagerCommand::FlushInput)

          # Main rendering loop
          Fiber.yield
          Benben.dlog!("Playback commencing")
Changes to src/rendering/renderer.cr.
144
145
146
147
148
149
150
151

152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170

171
172
173
174
175
176
177
178
179
180
181
182
144
145
146
147
148
149
150

151
152


















153

154
155
156

157
158
159
160
161
162
163







-
+

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
-



-







      jobs : Array(Job) = [] of Job

      # Used to check for duplicate output filenames.
      outputPaths : Hash(Path, Bool) = {} of Path => Bool

      # Get each file
      loop do
        file, fileType = Benben.fileHandler.next
        file = Benben.fileHandler.next
        break if file.nil?

        # Consistency check
        case file
        in VgmFile
          RemiLib.assert(fileType.vgm?)
        in ModuleFile
          RemiLib.assert(fileType.module?)
        in FlacFile
          RemiLib.assert(fileType.flac?)
        in OpusFile
          RemiLib.assert(fileType.opus?)
        in VorbisFile
          RemiLib.assert(fileType.vorbis?)
        in Mpeg1File
          RemiLib.assert(fileType.mpeg1?)
        in MidiFile
          RemiLib.assert(fileType.midi?)
        in PcmFile
        if file.is_a?(PcmFile)
          RemiLib.assert(fileType.pcm?)
          RemiLib.log.warn("File is already a PCM file, ignoring: " \
                           "#{Benben.fileHandler.currentFilename}")
          next
        in PlayableFile then raise "Forgot to update case statement"
        end

        # Get the output path
        outputPath : String = getOutputPath
        outputPath = Job.adjustOutputPathForFile(outputPath, file)

        # Check if the output path exists, and if it doesn't, attempt to create
214
215
216
217
218
219
220
221
222


223
224

225
226

227
228

229
230

231
232

233
234

235
236

237
238
239


240
241
242
243
244
245
246
195
196
197
198
199
200
201


202
203
204

205
206

207
208

209
210

211
212

213
214

215
216

217
218


219
220
221
222
223
224
225
226
227







-
-
+
+

-
+

-
+

-
+

-
+

-
+

-
+

-
+

-
-
+
+







          unless Benben.args["overwrite"].called?
            RemiLib.log.error("File exists: #{filename}")
            next
          end
        end

        # All good, store the new job
        case fileType
        in .vgm?
        case file
        in VgmFile
          jobs << VgmJob.new(file.as(VgmFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .module?
        in ModuleFile
          jobs << ModuleJob.new(file.as(ModuleFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .flac?
        in FlacFile
          jobs << FlacJob.new(file.as(FlacFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .opus?
        in OpusFile
          jobs << OpusJob.new(file.as(OpusFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .vorbis?
        in VorbisFile
          jobs << VorbisJob.new(file.as(VorbisFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .mpeg1?
        in Mpeg1File
          jobs << Mpeg1Job.new(file.as(Mpeg1File), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .midi?
        in MidiFile
          jobs << MidiJob.new(file.as(MidiFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in .pcm?
        in PcmFile
          raise "Should have been checked earlier"
        in .unknown?
          raise "Unexpectedly received FileType::Unknown"
        in PlayableFile
          raise "Unexpectedly received nil PlayableFile"
        end
        Fiber.yield
      end

      jobs
    end