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
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

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

* VGMs: working
* Modules (libxmp): working







|
|







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: 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
  # 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
      @taginfo = TagInfo.create(self)

    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
      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











    end

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








|
|

>











<
|








>
>
>
>
>
>
>
>
>
>
>







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?

    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


    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
#### Simple wrapper for MIDI files.
####

module Benben
  class MidiFile < PlayableFile
    class_getter! globalSoundfont : Haematite::SoundFont?
    class_getter globalSoundfontPath : String = ""


    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
      @taginfo = TagInfo.create(self)

    end





    private def maybeSetGlobalSoundfont(path : Path|String) : Bool



      # 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

    # 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?







>








|
|

>


>
>
>
>

>
>
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
>







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)

    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
        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
        if (sf = @soundfont).is_a?(Haematite::MmapSoundFont)
          sf.close
        end
        @soundfont = Haematite::SoundFont.new(@soundfontPath)
      end
    end



















    @[AlwaysInline]




    private def ensureFile : 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?






    end

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

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

    def unload : Nil

      @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
      @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
      @seq.not_nil!.render(dest, @arraypool)
      dest.size.to_i64!
    end

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







>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
>
>
|
>







>
>
>
>
>
>



|




|




>







<



|









|






|





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

    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
      ensureMIDIFileInRAM
      ((@file.not_nil!.length / 1000000) * Benben.config.sampleRate).to_u64!
    end

    def position : UInt64
      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

    end

    def play(loop? : Bool) : Nil
      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
      ensureFileInRAM
      @seq.not_nil!.render(dest, @arraypool)
      dest.size.to_i64!
    end

    @[AlwaysInline]
    def decode(dest : Array(Float64)) : Int64
      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
  class ModuleFile < PlayableFile
    alias Xmp = RemiXmp

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

    def initialize(@filename : Path|String)
      ensureFile
      @taginfo = TagInfo.create(self)

    end

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








|
|

>







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?

    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
        else
          IO.copy(file, io)
        end
      end
      io.to_slice
    end

    @[AlwaysInline]
    private def ensureFile : Nil
      unless @loaded
        @ctx.load(loadData)
        @loaded = true
      end




    end

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








<
|




>
>
>
>







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


    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
    end

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

    def unload : Nil

      @dtinfo = nil
      @ctx.unload if @loaded
      @loaded = false
      super
    end

    def totalSamples : UInt64
      @ctx.info.lengthSamples
    end

    def pattern







>



<







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

    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
#### Simple wrapper for MPEG-1 files.
####

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

    def initialize(@filename : Path|String)
      ensureFile
      @taginfo = TagInfo.create(self)

    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
      if @decoder.nil?
        @decoder = RemiMpg123::Decoder.new(@filename)
      end









    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







|
|

>














<
|



>
>
>
>
>
>
>
>
>







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?

    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


    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
    @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
      @taginfo = TagInfo.create(self)

    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







|
|

>







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

    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
                   # Opus at 48Khz then resample.
                   @needResampling = true
                   @sampleRate = 48000
                   Opus::Decoder.new(48000, 2)
                 end
    end

    private def ensureFile : Nil
      if @demuxer.nil?
        RemiLib.assert(@decoder.nil?)
        startDecoder
      else
        RemiLib.assert(!@decoder.nil?)
      end




    end

    def demuxer : OggDemuxer
      ensureFile
      @demuxer.not_nil!
    end








|






>
>
>
>







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

    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

    def rewind : Nil
      unload
      ensureFile
    end

    def unload : Nil

      @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







>




<










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

    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
#### 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
      @taginfo = TagInfo.create(self)

    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
      @file = RemiAudio::Formats::AudioFile.open(filename) if @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"







|
|

>













<
|

>
>
>
>
>
>
>
>
>







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?

    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


    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
#### 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 "../taginfo"

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

module Benben
  abstract class PlayableFile
















    @filename : Path|String = ""
    @taginfo : TagInfo?



































































































    def filename : String
      case ret = @filename
      in String then ret
      in Path then ret.to_s
      end
    end







>








>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|

>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>







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
    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

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

    abstract def totalSamples : UInt64









    def unload : Nil















    end


  end



end








>
>
>
>
>
>
>
>
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
|
>
>
|
>
>
>
|
>
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

    # 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

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

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


    def initialize(@filename : Path|String)
      ensureFile
      @taginfo = TagInfo.create(self)

    end

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

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

    def ensureFile
      if @fromIO
        raise "@file was not set but @fromIO is true" if @file.nil?
      elsif @file.nil?
        @file = Yuno::VgmFile.load(@filename)
      end













    end

    @[AlwaysInline]
    def file : Yuno::VgmFile
      ensureFile
      @file.not_nil!
    end







<



>

|
|

>


|




|





|





>
>
>
>
>
>
>
>
>
>
>
>
>







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

    @fromIO : Bool = false
    @file : Yuno::VgmFile?
    @totalSamples : UInt64? = nil
    @samplesPerLoop : UInt64? = nil

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

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

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

    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




















        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











  end
end



























>
>
>
>
>
>
>
>
>
>
>
|
|
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
>
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

    # :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
    @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
      @taginfo = TagInfo.create(self)

    end

    def self.test(filename : Path|String) : Bool
      testVorb = VorbisFile.new(filename)
      testVorb.channels == 2 # We only support stereo Vorbis files
    rescue Exception
      false







|
|

>







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

    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
        demux.rewind
        demux.nextPacket
        demux.nextPacket
        demux.nextPacket
      end
    end

    private def ensureFile : Nil
      if @demuxer.nil?
        RemiLib.assert(@decoder.nil?)
        startDecoder
      else
        RemiLib.assert(!@decoder.nil?)
      end




    end

    def sampleRate : UInt32
      ensureFile
      @sampleRate
    end








|






>
>
>
>







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

    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

    def rewind : Nil
      unload
      ensureFile
    end

    def unload : Nil

      @decoder = nil
      @demuxer = nil
      @io.try(&.close)
      @io = nil
      super
    end
  end
end







>




<



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

    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
  # :nodoc:
  macro dlog!(msg)
    {% if flag?(:benben_debug) %}
      RemiLib.log.dlog!({{msg}})
      RemiLib.log.debugStream.flush
    {% end %}
  end













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"







>
>
>
>
>
>
>
>
>
>
>
>
>
















<
<
<
<
<
<
<
<
<
<
<
<
<







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













  ###
  ### 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
#### 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/*"

####
#### 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

      # 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
    # PlayableFile instances that need caching, while also checking for bad
    # files and printing the results as-needed.

    def initialize
      Yuno.dlog!("File handler initializing")
      # Loop through each positional argument and attempt to load each one.
      Benben.args.positionalArgs.each do |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 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





      @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)
    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}")
        if arg.downcase.ends_with?(".xspf")
          loadXspfFile(Path[arg])
        elsif arg.downcase.ends_with?(".jspf")
          loadJspfFile(Path[arg])
        else

          addLocal(arg)
        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://")

        Yuno.dlog!("Downloading from an HTTP/HTTPS URL: #{arg}")
        addHttp(URI.parse(arg))

      when arg.starts_with?("gemini://")

        Yuno.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

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

     @[AlwaysInline]
    private def clearLine : Nil
      RemiLib::Console.cursor(STDOUT, 1)



      RemiLib::Console.erase(STDOUT)

    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).
    private def printCurrentFile(filename : String, label : String) : Nil
      maxLength = 80 - label.size - 6

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

    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"
      extra = " #{amount.prettySize} / #{total.prettySize} #{((amount / total) * 100).to_i32!}% #{avgStr}"
      maxLength -= extra.size


      clearLine
      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.
    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

    # 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
      avgStr = "#{avg.prettySize}/s"
      extra = " #{amount.prettySize} #{avgStr}"
      maxLength -= extra.size

      clearLine


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




    # Cleans up the output once printing is done.
    private def finishPrinting : Nil
      clearLine
      STDOUT.flush

    end


    # Logs an error message that will be displayed after loading is finished.
    private def logError(string : String) : Nil



      @errors << string

    end

    # Prints all logged error messages, then clears them.
    private def printErrors : Nil
      unless @errors.empty?
        STDERR.flush
        @errors.each do |err|
          RemiLib.log.error(err)
        end
        @errors.clear # No need to keep them around.
      end


    end

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

    # Returns the number of files that this `FileManager` knows about.
    def size
      @files.size







|













<
<
<
<
|
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<






|
<
|
>

|
|
|
<
<

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<

|








>
>
>
>








<


<
<
<













<
|
|
<
<
<
<
<
<
<
<
<
<





|




|
>
|













>
|
|


>
|
|


<










|
|
<
|
|
>
>
>
|
>


<
>
|
<
<
<
|
|
|
>
|
>
>
>
>
>

<
<
<
<
|
|
|
<
<

>
|
<
>
|
|
>
>
>
|
|
|
|
<
<
|
|
|
|
|
>
>
>
|
|
<
<
<
|
|
|
<
<
|

|
>
>
|
|
>
>
>
|
<
|
|
<
>
|

>
|
|
>
>
>
|
>
|
|
<
|
<
<
<
<

<

>
>







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 "./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




    @files : Array(PlayableFile) = [] of PlayableFile








































































































































































































    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 check for bad files and

    # printing the results as-needed, as well as create the virtual play queue
    # based on the command line arguments.
    def initialize
      Benben.dlog!("File handler initializing")
      # Attempt to load each positional argument as a file or directory.
      Benben.args.positionalArgs.each { |arg| addFile(arg) }



































      # Are we playing MIDI files?  Ensure we have a SoundFont.
      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?

        @files.shuffle!
      end



    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


    private def addLocal(file : PlayableFile) : Nil
      @files << file










    end

    private def addFile(arg : String) : Nil
      case
      when File.exists?(arg) && !Dir.exists?(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])
        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}")
        # 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}")
        # Benben.dlog!("Downloading from a Gemini URL: #{arg}")
        # addGemini(URI.parse(arg))

      else

        # 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.

    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


    # 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
      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





    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"
                     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)


                     # true
                   when "file"
                     if newFile = PlayableFile.create(URI.decode(loc.path))
                       newFile.unload
                       addLocal(newFile)
                       true
                     else
                       false
                     end
                   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?
            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

          numLoaded += 1




        end

      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
    #
    # 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)
      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}
        end

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

        file, typ = @files[@currentTrack.lazy_get].load(self)
        if file.nil? || typ.unknown?
          Benben.ui.queueError("Can't load file: #{currentFileBasename}")
        else
          return {file, typ}
        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






    # 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)
      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}
        end

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

          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}
          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







|







|






|



|
|
<

|















>
>
>
>
>









|







|







|













|
|
>







|

<
<







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 : 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
        end

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

        if (file = @files[@currentTrack.lazy_get]).ensureFile
          return file

        else
          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 : 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
        end

        if @currentTrack.lazy_get < 0
          # Too far back
          if @files.empty?
            # ... and no files
            @currentTrack.set(-1)
            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

        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
          end


        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
    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?
        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?
        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?
        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?
        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?
        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?
        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?
        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?
        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?
        raise "Cannot play an unknown file type"
      end
    end

    def getNextFile

      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
        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)

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

          # Main rendering loop
          Fiber.yield
          Benben.dlog!("Playback commencing")







|
|
|





|





|





|





|





|





|





|





|





>











|







|







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) : 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 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 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 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 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 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 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 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 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 = 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)

          # 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
      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
        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
          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







|

<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
<
|
<



<







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 = Benben.fileHandler.next
        break if file.nil?

















        if file.is_a?(PcmFile)

          RemiLib.log.warn("File is already a PCM file, ignoring: " \
                           "#{Benben.fileHandler.currentFilename}")
          next

        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
          unless Benben.args["overwrite"].called?
            RemiLib.log.error("File exists: #{filename}")
            next
          end
        end

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

      jobs
    end








|
|

|

|

|

|

|

|

|

|
|







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 file
        in VgmFile
          jobs << VgmJob.new(file.as(VgmFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in ModuleFile
          jobs << ModuleJob.new(file.as(ModuleFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in FlacFile
          jobs << FlacJob.new(file.as(FlacFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in OpusFile
          jobs << OpusJob.new(file.as(OpusFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in VorbisFile
          jobs << VorbisJob.new(file.as(VorbisFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in Mpeg1File
          jobs << Mpeg1Job.new(file.as(Mpeg1File), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in MidiFile
          jobs << MidiJob.new(file.as(MidiFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in PcmFile
          raise "Should have been checked earlier"
        in PlayableFile
          raise "Unexpectedly received nil PlayableFile"
        end
        Fiber.yield
      end

      jobs
    end