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

Overview
Comment:Merge current work from library-consolidation branch
Downloads: Tarball | ZIP archive
Timelines: family | ancestors | descendants | both | trunk
Files: files | file ages | folders
SHA3-256: 04ad13987180da288841ce02a511c001b67742453b128cdb223b6688a9d05c9e
User & Date: alexa 2024-09-05 10:07:20.186
Context
2024-09-05
10:08
Update NEWS and TRUNKSTATUS check-in: 9e227e475c user: alexa tags: trunk
10:07
Merge current work from library-consolidation branch check-in: 04ad139871 user: alexa tags: trunk
10:06
Add WavPack support. Remove some useless finalize methods. check-in: 64d9455ed9 user: alexa tags: library-consolidation
2024-09-03
11:05
Use a newer version of libremiliacr so we can remove the dependency on RemiConf (which was merged into libremiliacr) check-in: cbf56c05e8 user: alexa tags: trunk
Changes
Side-by-Side Diff Ignore Whitespace Patch
Changes to shard.lock.
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
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










-
-
-
-
+
-
-
-
+
+

-
-
-
+
+
+





-
-
-
-




-
-
-
-







version: 2.0
shards:
  haematite:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/Haematite
    version: 0.5.3

  libremiliacr:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/libremiliacr
    version: 0.91.0

  remiaudio:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remiaudio
    version: 0.6.3

  racodecs:
  remimpg123:
    fossil: https://nanako.mooo.com/fossil/remimpg123
    version: 0.1.4
    fossil: https://nanako.mooo.com/fossil/racodecs
    version: 0.1.0+fossil.commit.b164f0f81df9bbc39488de99cce2cab13d8aa3a6465802be4612ffc2e542b732

  remiportaudio:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remiportaudio
    version: 0.1.2
  remiaudio:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remiaudio
    version: 0.7.0

  remislang:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remislang
    version: 0.1.2

  remisound:
    fossil: https://nanako.mooo.com/fossil/remisound
    version: 0.1.2

  remixmp:
    fossil: https://nanako.mooo.com/fossil/remixmp
    version: 0.90.1

  remixspf:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remixspf
    version: 0.90.1

  yunosynth:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/yunosynth
    version: 0.4.4+fossil.commit.90e380c31064e2202314916cdc549b10f413cc757f5fcc16d548f7a86e5b7bef

  zstd:
    git: https://github.com/didactic-drunk/zstd.cr.git
    version: 1.2.0
Changes to shard.yml.
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
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















+
+
+
+
+
+
+









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







-
-
-
-
-
-
-
-
    main: src/main.cr

dependencies:
  libremiliacr:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/libremiliacr
    version: 0.91.0

  remiaudio:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remiaudio
    version: 0.7.0

  racodecs:
    fossil: https://nanako.mooo.com/fossil/racodecs

  yunosynth:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/yunosynth
    #version: 0.4.4
    commit: 90e380c31064e2202314916cdc549b10f413cc757f5fcc16d548f7a86e5b7bef

  haematite:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/Haematite
    version: 0.5.3

  remiaudio: # We use a slightly newer version of RemiAudio to get QOA support.
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remiaudio
    version: 0.6.3

  remixspf:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remixspf
    version: 0.90.1

  remisound:
    fossil: https://nanako.mooo.com/fossil/remisound
    version: 0.1.2

  remislang:
    fossil: https://chiselapp.com/user/MistressRemilia/repository/remislang
    version: 0.1.2

  remixmp:
    fossil: https://nanako.mooo.com/fossil/remixmp
    version: 0.90.1

  remimpg123:
    fossil: https://nanako.mooo.com/fossil/remimpg123
    version: 0.1.4

  #remiwavpack:
  #  fossil: https://nanako.mooo.com/fossil/remiwavpack
  #  tag: tip
Changes to spec/config_spec.cr.
1
2
3
4
5

6
7
8
9
10
11
12
1
2
3
4

5
6
7
8
9
10
11
12




-
+







require "./spec_helper"
require "../src/config/*"
require "../src/reverb-monkeys"

@[RemiConf::ConfigOpts(filename: "benben.yaml", format: :yaml)]
@[RemiLib::Config::ConfigOpts(filename: "benben.yaml", format: :yaml)]
class Benben::Config
  def checkForDefaults : Nil
    self.stderrLogging.should eq DEF_STDERR_LOGGING
    self.audioDriver.should eq DEF_DRIVER
    self.bufferSize.should eq DEF_BUFFER_SIZE
    self.sampleRate.should eq DEF_SAMPLE_RATE
    self.repeatList?.should eq DEF_REPEAT_LIST
Changes to spec/theme_spec.cr.
1
2
3
4

5
6
7
8
9
10
11
1
2
3

4
5
6
7
8
9
10
11



-
+







require "./spec_helper"
require "../src/config/theme"

@[RemiConf::ConfigOpts(filename: "benben.yaml", format: :yaml)]
@[RemiLib::Config::ConfigOpts(filename: "benben.yaml", format: :yaml)]
class Benben::Theme
  def checkForDefaults : Nil
    self.version.should eq THEME_FORMAT_VERSION

    self.bgColor.should eq DEF_BG_COLOR
    self.fgColor.should eq DEF_FG_COLOR
    self.bannerColor.should eq DEF_BANNER_COLOR
Changes to src/audio-driver.cr.
9
10
11
12
13
14
15
16

17
18
19
20
21
22
23
24
9
10
11
12
13
14
15

16

17
18
19
20
21
22
23







-
+
-







#### 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 "remisound"
require "remiaudio"
require "./common"

####
#### Abstract Audio Driver Interface
####
#### Audio drivers can be disabled by sending the appropriate -D option to the
#### compiler.  At least one audio driver must be enabled, however.
####
50
51
52
53
54
55
56
57

58
59
60
61
62
63
64
49
50
51
52
53
54
55

56
57
58
59
60
61
62
63







-
+







        # Use PortAudio for output.
        PortAudio
      {% end %}
    end

    # Base interface for all audio drivers.
    class AudioDriver
      @driver = uninitialized RemiSound::AudioDevice
      @driver = uninitialized RemiAudio::Drivers::AudioDevice

      # Creates an `AudioDriver` instance based on the value of `drv`.  If this is
      # `Any`, then Benben will select an audio driver automatically.
      def initialize(drv : Driver)
        {% begin %}
          # Adjust drv in case it's set to Any
          drv = if drv.any?
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
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







-
+



-
+



-
+




-
+






-
+




















-
+








          # Create the AudioDriver instance.
          @driver = AudioDriver.makeDevice(drv)
          @driver.bufferSize = Benben.config.bufferSize.to_u32!
          @driver.start
        {% end %}
      end

      protected def self.makeDevice(drv : Driver) : RemiSound::AudioDevice
      protected def self.makeDevice(drv : Driver) : RemiAudio::Drivers::AudioDevice
        {% begin %}
          ret = case drv
                    {% unless flag?(:benben_no_pulse_audio) %}
                    when .pulse_audio? then RemiSound::PulseDevice.new(Benben.config.sampleRate, 32, 2)
                    when .pulse_audio? then RemiAudio::Drivers::PulseAudio::PulseDevice.new(Benben.config.sampleRate, 32, 2)
                    {% end %}

                    {% unless flag?(:benben_no_port_audio) %}
                    when .port_audio? then RemiSound::PortDevice.new(Benben.config.sampleRate, 32, 2)
                    when .port_audio? then RemiAudio::Drivers::PortAudio::PortDevice.new(Benben.config.sampleRate, 32, 2)
                    {% end %}

                    {% unless flag?(:benben_no_ao) %}
                    # Output must be 8-, 16-, or 24-bit for Libao.  We'll use 16-bit.
                    when .ao? then RemiSound::AoDevice.new(Benben.config.sampleRate, 16, 2)
                    when .ao? then RemiAudio::Drivers::Ao::AoDevice.new(Benben.config.sampleRate, 16, 2)
                    {% end %}
                else raise "Unexpected audio driver: #{drv}"
                end

          {%unless flag?(:benben_no_pulse_audio) %}
            # We can set some additional info for PulseAudio.
            if ret.is_a?(RemiSound::PulseDevice)
            if ret.is_a?(RemiAudio::Drivers::PulseAudio::PulseDevice)
              ret.programName = "Benben"
              ret.streamName = "Benben Audio Playback"
            end
          {% end %}

          ret
        {% end %}
      end

      def deinit : Nil
        @driver.stop
      end

      @[AlwaysInline]
      def writeBuffer(buf : Array(Float32)) : Nil
        {% if flag?(:benben_no_port_audio) %}
          @driver.writeBuffer(buf)
        {% else %}
          begin
            @driver.writeBuffer(buf)
          rescue RemiPA::PaError
          rescue RemiAudio::RemiAudioError
          end
        {% end %}
      end
    end
  end
{% end %}

#require "./audio-drivers/*"
Changes to src/audio-formats/bindings/vorbis.cr.
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
9
10
11
12
13
14
15

16
17
18
19
20
21
22







-







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

####
#### Basic libvorbis Bindings
####

@[Link(ldflags: "`pkg-config vorbis --libs`")]
lib LibVorbis
234
235
236
237
238
239
240
241

242
243
244
245
246
247
248
233
234
235
236
237
238
239

240
241
242
243
244
245
246
247







-
+







    #
    # This only checks up to the first 8192 bytes of the file.
    def self.test(filename : Path|String) : Bool
      Benben.dlog!("Testing #{filename} to see if it's a Vorbis file")

      ret = false
      File.open(filename, "rb") do |file|
        demux = Benben::OggDemuxer.new(file, 8192)
        demux = RemiAudio::Demuxers::Ogg.new(file, 8192)

        packet1 = demux.nextPacket(8192)
        return false if packet1.empty?
        Decoder.withPacket(packet1, true, false, 0, 0) do |packet1Ptr|
          if LibVorbis.vorbis_synthesis_idheader(packet1Ptr)
            info : LibVorbis::VorbisInfo = LibVorbis::VorbisInfo.new
            comment : LibVorbis::VorbisComment = LibVorbis::VorbisComment.new
264
265
266
267
268
269
270
271

272
273
274
275
276
277
278
263
264
265
266
267
268
269

270
271
272
273
274
275
276
277







-
+







              end # demux.nextPacket(8192)
            end
          end # if LibVorbis.vorbis_synthesis_idheader(packet1Ptr)
        end # Decoder.withPacket(packet1, true, false, 0, 0) do |packet1Ptr|
      end # File.open(filename, "rb") do |file|

      ret
    rescue ::Benben::OggDemuxer::MissingMarkerError
    rescue RemiAudio::Demuxers::Ogg::MissingMarkerError
      false
    rescue err : Exception
      Benben.dlog!("Error in Vorbis::Decoder.test: #{err} (#{err.backtrace})")
      false
    end

    def sampleRate : Int64
Changes to src/audio-formats/flacfile.cr.
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
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







-
-
+
+









-
+













-
+














-
+




-
+


-
+








module Benben
  # A simple wrapper for module files.  This stores raw module data in RAM by
  # 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?
    @flac : RAFlac::Flac?
    @flacDec : RAFlac::Decoder?

    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, _, _ = RAFlac::Flac.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!)
        @flac = RAFlac::Flac.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
    def flac : RAFlac::Flac
      ensureFile
      @flac.not_nil!
    end

    def decoder : RAFlac::StreamedDecoder
    def decoder : RAFlac::Decoder
      ensureFile
      if @flacDec.nil?
        @flacDec = RAFlac::StreamedDecoder.new(@flac.not_nil!, Benben.config.bufferSize)
        @flacDec = RAFlac::Decoder.new(@flac.not_nil!, Benben.config.bufferSize)
      end
      @flacDec.not_nil!
    end

    def sampleFormat
      ensureFile
      @flac.not_nil!.sampleFormat
94
95
96
97
98
99
100
101
102
103



104
105
106
94
95
96
97
98
99
100



101
102
103
104
105
106







-
-
-
+
+
+



    end

    def rewind : Nil
      @flac.try(&.rewind)
      @flacDec.try(&.rewind)
    end

    @[AlwaysInline]
    def decode(dest : Array(Int32)) : Int32
      self.decoder.renderStereo(dest)
    def decode(dest : Array(Float32)) : Int32
      ensureFile
      self.decoder.decode(dest)
    end
  end
end
Changes to src/audio-formats/midifile.cr.
172
173
174
175
176
177
178
179
180

181
182
183

184
185
186
187

188
189
190

191
192
193
172
173
174
175
176
177
178


179
180
181

182
183
184


185
186
187

188
189
190
191







-
-
+


-
+


-
-
+


-
+



      @seq.not_nil!.play(@file.not_nil!, loop?)
    end

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

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

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

32
33
34
35
36
37
38
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39







+







  # Bzip2, or ZStandard.
  class ModuleFile < PlayableFile
    alias Xmp = RemiXmp

    @ctx : Xmp::Context = Xmp::Context.new
    @loaded : Bool = false
    @dtinfo : RemiXmp::ModuleInfo?
    @bufI16 : Array(Int16) = [] of Int16

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

180
181
182
183
184
185
186






187
188
189
190
191
192

























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







+
+
+
+
+
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
          ret = @ctx.info(true)
        end
      else
        @ctx.info(false)
      end
    end

    def decode(dest : Array(Float32)) : Int32
      {% begin %}
        ensureFile
        unless dest.size == @bufI16.size
          @bufI16 = Array(Int16).new(dest.size, 0i16)
        end
    @[AlwaysInline]
    def decode(dest : Array(Int16)) : Nil
      @ctx.renderFrame(dest)
    end
  end
end

        @ctx.renderFrame(@bufI16)
        @bufI16.size.times do |i|
          dest.put!(i, @bufI16.get!(i) * {{ 1.0f32 / 32768.0f32 }})
        end
        dest.size
      {% end %}
    end

    def decode(dest : Array(Float64)) : Int32
      {% begin %}
        ensureFile
        unless dest.size == @bufI16.size
          @bufI16 = Array(Int16).new(dest.size, 0i16)
        end

        @ctx.renderFrame(@bufI16)
        @bufI16.size.times do |i|
          dest.put!(i, @bufI16.get!(i) * {{ 1.0 / 32768.0 }})
        end
        dest.size
      {% end %}
    end
  end
end
Changes to src/audio-formats/mpeg1file.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
33
34
35
36
37
38
39

40
41
42
43
44
45
46
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







-
















-
-
-
-


-
+







#### 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 "remimpg123"
require "./playablefile"

####
#### 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
        mpegInfo.mode != RACodecs::MPEG1::Low::MODE_MONO
      else
        false
      end
    end

    def ensureFile : Bool
      if @decoder.nil?
56
57
58
59
60
61
62
63
64
65



66
67
68
69
70
71
72
51
52
53
54
55
56
57



58
59
60
61
62
63
64
65
66
67







-
-
-
+
+
+







      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
                              in .album? then Mpg123::RvaMode::Album
                              in .disabled? then RACodecs::MPEG1::Low::RvaMode::Off
                              in .mix? then RACodecs::MPEG1::Low::RvaMode::Mix
                              in .album? then RACodecs::MPEG1::Low::RvaMode::Album
                              end
      Benben.dlog!("Set RVA mode for MPEG-1 decoder: #{@decoder.not_nil!.rva}")
    end

    def sampleRate
      ensureFile
      @decoder.not_nil!.sampleRate
80
81
82
83
84
85
86
87

88
89
90
91
92
93
94
75
76
77
78
79
80
81

82
83
84
85
86
87
88
89







-
+







    def layer
      ensureFile
      @decoder.not_nil!.layer
    end

    def totalSamples : UInt64
      ensureFile
      @decoder.not_nil!.totalSamples
      @decoder.not_nil!.totalSamples.to_u64
    end

    def id3
      ensureFile
      @decoder.not_nil!.id3
    end

115
116
117
118
119
120
121
122
123

124
125

126
127
128
110
111
112
113
114
115
116


117
118

119
120
121
122







-
-
+

-
+




    @[AlwaysInline]
    def pos=(value) : Int64
      ensureFile
      @decoder.not_nil!.pos = value
    end

    @[AlwaysInline]
    def decode(dest : Array(Float32)) : Int64
    def decode(dest : Array(Float32)) : Int32
      ensureFile
      @decoder.not_nil!.decode(dest)
      @decoder.not_nil!.decode(dest).to_i32!
    end
  end
end
Deleted src/audio-formats/ogg.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
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














































































































































































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

####
#### Basic Ogg Demuxer
####

module Benben
  class OggDemuxer
    MAGIC = "OggS"
    MAGIC_BYTES = MAGIC.to_bytes
    PAGE_VERSION = 0 # Mandated to always be zero by the specifications

    @[Flags]
    enum PageType : UInt8
      Continuation = 0x01u8
      StreamStart  = 0x02u8
      StreamEnd    = 0x04u8
    end

    class Error < Exception
    end

    class BadVersion < Error
    end

    class MissingMarkerError < Error
    end

    @io : IO
    @packetsDecoded : Int64 = 0i64
    getter? atPage : Bool = false
    getter startPos : Int64 = 0i64
    getter type : PageType = PageType::None
    getter granulePos : UInt64 = 0u64
    getter serial : UInt32 = 0u32
    getter seqNumber : UInt32 = 0u32
    getter crc : UInt32 = 0u32
    @lastSegment : UInt32 = 0u32
    @segmentLengths : Array(UInt8) = Array(UInt8).new(257, 0u8) # One extra for safety
    @curSeg : Int32 = 0
    getter pageNumber : UInt64 = 0u64

    def packetsDecoded : Int64
      @packetsDecoded - 1
    end

    def packetsDecoded=(@packetsDecoded : Int64)
    end

    def initialize(@io : IO, upto : Int? = nil)
      # Possibly read the first segment
      advancePage(upto)
    end

    def rewind : Nil
      @io.pos = 0
      @packetsDecoded = 0
      advancePage(nil)
    end

    @[AlwaysInline]
    private def findNextMarker(upto : Int?) : Nil
      start = @io.pos
      loop do
        return if @io.read_string(4) == MAGIC
        @io.read_byte
        if upto && (@io.pos - start >= upto)
          raise MissingMarkerError.new("Could not find an Ogg page marker before #{upto} bytes")
        end
      end
    end

    private def advancePage(upto : Int?) : Nil
      findNextMarker(upto) unless @io.read_string(4) == MAGIC
      if byt = @io.read_byte
        if byt != PAGE_VERSION
          raise BadVersion.new("Invalid Ogg version (byte position: #{@io.pos - 1})")
        end
      else
        raise IO::EOFError.new("Unexpected end of stream (could not find Ogg version)")
      end

      @startPos = @io.pos.to_i64! - 5
      @type = PageType.from_value(@io.read_byte || raise IO::EOFError.new("Could not read Ogg page type"))
      @granulePos = @io.readUInt64
      @serial = @io.readUInt32
      @seqNumber = @io.readUInt32
      @crc = @io.readUInt32
      @curSeg = 0
      @lastSegment = 0
      if numSegs = @io.read_byte
        numSegs.times do |_|
          if len = @io.read_byte
            @segmentLengths[@lastSegment] = len
            @lastSegment += 1
          else
            raise IO::EOFError.new("Could not read segment length: unexpected end of stream")
          end
        end
      else
        raise IO::EOFError.new("Could not read num segments: unexpected end of stream")
      end
      @pageNumber += 1
      @atPage = true
    rescue IO::EOFError
      @atPage = false
    end

    @[AlwaysInline]
    protected def advanceSegment(upto : Int?) : Nil
      if @atPage
        @curSeg += 1

        # Advance the page's current segment by one, and check if we've reached
        # the end of this page's segments.
        advancePage(upto) if @curSeg == @lastSegment
      else
        # No page yet, read the first one
        advancePage(upto)
      end
    rescue IO::EOFError
      @atPage = false
    end

    def nextPacket(upto : Int? = nil) : Bytes
      ret = IO::Memory.new
      lastLen : UInt8 = 0
      len : UInt8 = 0
      pos : Int32 = 0
      buf : Bytes = Bytes.new(256)

      while @atPage
        lastLen = len
        len = @segmentLengths[@curSeg]
        if len == 0
          break if lastLen == 255 # Packet was a multiple of 255?
        end

        pos = @io.read_fully(buf[...len])
        if pos == len
          ret.write(buf[...pos])
        else
          raise Error.new("Could not read enough data for segment #{@curSeg} " \
                          "(wanted #{len} bytes, got #{pos}")
        end

        # If the segment is less than 255 bytes, end of packet
        break if len < 255

        # Goto the next segment
        advanceSegment(upto)
      end # while @atPage

      # Goto the next segment, then return
      advanceSegment(upto)
      @packetsDecoded += 1
      ret.to_slice
    end
  end
end
Changes to src/audio-formats/opusfile.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

33
34
35
36
37
38
39
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







-















-
+







#### 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 "./ogg"
require "./bindings/opus"
require "./playablefile"

####
#### Simple wrapper for Opus files.
####

module Benben
  # A simple wrapper for Opus files.
  class OpusFile < PlayableFile
    OPUS_ID_HEADER = "OpusHead".to_slice
    OPUS_COMMENT_HEADER = "OpusTags".to_slice
    OPUS_VERSION = 1

    @io : IO?
    @demuxer : OggDemuxer?
    @demuxer : RemiAudio::Demuxers::Ogg?
    @decoder : Opus::Decoder?
    @preSkip : UInt16 = 0u16
    @inputSampleRate : UInt32 = 0u32
    @outputGain : Float32 = 0.0f32
    @channels : UInt8 = 0u8
    @tagPacket : Bytes = Bytes.new(0)
    getter? needResampling : Bool = false
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
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







-
+

















-
+







    # `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
      Benben.dlog!("Testing #{filename} to see if it's an Opus file")
      ret = false
      File.open(filename, "rb") do |file|
        demux = OggDemuxer.new(file, 8192)
        demux = RemiAudio::Demuxers::Ogg.new(file, 8192)
        rawPacket = demux.nextPacket
        ret = (rawPacket[0..7] == OPUS_ID_HEADER &&
               rawPacket[8] == OPUS_VERSION &&
               rawPacket[9] == 2) # We only support 2 channels
      end
      ret
    rescue Exception
      false
    end

    private def loadOgg : Nil
      RemiLib.assert(@demuxer.nil?)
      if @io.nil?
        @io = File.open(@filename, "rb")
      else
        @io.not_nil!.close
      end
      @demuxer = OggDemuxer.new(@io.not_nil!, UInt16::MAX)
      @demuxer = RemiAudio::Demuxers::Ogg.new(@io.not_nil!, UInt16::MAX)
    end

    private def loadOpus : Nil
      loadOgg

      # This should have already been checked by the FileHandler, but we check
      # here as well for consistency.
158
159
160
161
162
163
164
165

166
167
168
169
170
171
172
157
158
159
160
161
162
163

164
165
166
167
168
169
170
171







-
+







      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load Opus #{@filename}: #{err}")
      false
    end

    def demuxer : OggDemuxer
    def demuxer : RemiAudio::Demuxers::Ogg
      ensureFile
      @demuxer.not_nil!
    end

    def decoder : Opus::Decoder
      ensureFile
      @decoder.not_nil!
213
214
215
216
217
218
219





220
221
222
223
224
225
226
227
228
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232







+
+
+
+
+









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

    def decode(dest : Array(Float32)) : Int32
      # TODO
      raise NotImplementedError.new("Use other decode method")
    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.
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
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32




33
34
35
36
37
38
39







+







-
-
-
-







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

module Benben
  class PcmFile < PlayableFile
    @file : RemiAudio::Formats::AudioFile?
    @buf64 : Array(Float64) = [] of Float64

    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

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







-
-
-
-
+













-
+



    @[AlwaysInline]
    def pos=(value) : Int64
      ensureFile
      @file.not_nil!.pos = value * self.channels
      @file.not_nil!.pos.to_i64.tdiv(self.channels)
    end

    @buf64 : Array(Float64) = [] of Float64

    @[AlwaysInline]
    def decode(dest : Array(Float32)) : Int64
    def decode(dest : Array(Float32)) : Int32
      if @buf64.size != dest.size
        @buf64 = Array(Float64).new(dest.size, 0.0)
      end

      ensureFile
      numRead = @file.not_nil!.read(@buf64)
      if numRead < @buf64.size
        @buf64.fill(0.0, numRead..)
      end

      dest.size.times do |idx|
        dest.unsafe_put(idx, @buf64.unsafe_fetch(idx).to_f32!)
      end
      @buf64.size.to_i64!
      @buf64.size.to_i32!
    end
  end
end
Changes to src/audio-formats/playablefile.cr.
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
9
10
11
12
13
14
15

16
17
18
19
20
21
22







-







#### 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
60
61
62
63
64
65
66


67
68
69
70
71
72
73
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74







+
+







        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 ".wv"
        FileType::WavPack if WavPackFile.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)
      when ".qoa"
103
104
105
106
107
108
109


110
111
112
113
114
115
116
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119







+
+







        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 WavPackFile.test(filename)
        ret = FileType::WavPack
      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
135
136
137
138
139
140
141

142
143
144
145
146
147
148
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152







+







      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 .qoa? then return QoaFile.new(filename)
      in .wav_pack? then return WavPackFile.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}")
225
226
227
228
229
230
231


232
233
229
230
231
232
233
234
235
236
237
238
239







+
+


    abstract def totalSamples : UInt64

    # Ensures the underlying file context used for decoding is in memory.
    abstract def ensureFile : Bool

    # Unloads the underlying file context from memory.
    abstract def unload : Nil

    abstract def decode(dest : Array(Float32)) : Int32
  end
end
Changes to src/audio-formats/qoafile.cr.
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
26
27
28
29
30
31
32




33
34
35
36
37
38
39







-
-
-
-








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

    def finalize
      @ctx = nil
    end

    def self.test(filename : Path|String) : Bool
      if qoa = Qoa::Decoder.test(filename)
        qoa.channels == 2
      else
        false
      end
    rescue Exception
65
66
67
68
69
70
71
72

73
74
75
76
77
78
79
61
62
63
64
65
66
67

68
69
70
71
72
73
74
75







-
+







    def channels
      ensureFile
      @ctx.not_nil!.qoa.channels
    end

    def totalSamples : UInt64
      ensureFile
      @ctx.not_nil!.totalSamples
      @ctx.not_nil!.totalSamples.to_u64
    end

    def totalFrames : UInt64
      ensureFile
      @ctx.not_nil!.totalFrames.to_u64!
    end

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
89
90
91
92
93
94
95

96
97
98
99
100


101
102
103
104
105
106
107
108

109
110
111
112







-
+




-
-
+







-
+



      raise NotImplementedError.new("Use #framePos= for QoaFile")
    end

    # Seeks to the given frame.
    @[AlwaysInline]
    def framePos=(value) : Nil
      ensureFile
      @ctx.not_nil!.seek(value) # Note: value is a FRAME INDEX here
      @ctx.not_nil!.pos = value # Note: value is a FRAME INDEX here
      @samplesDecoded = @ctx.not_nil!.framesDecoded.to_i64 * Qoa::FRAME_LEN
      @samplesDecoded *= @ctx.not_nil!.qoa.channels
    end

    @[AlwaysInline]
    def decode(dest : Array(Float32)) : Int64
    def decode(dest : Array(Float32)) : Int32
      ensureFile
      numRead = @ctx.not_nil!.decode(dest)
      if numRead < dest.size
        dest.fill(0.0f32, numRead..)
      end

      @samplesDecoded += numRead.tdiv(@ctx.not_nil!.qoa.channels)
      numRead.to_i64!
      numRead.to_i32!
    end
  end
end
Changes to src/audio-formats/vgmfile.cr.
114
115
116
117
118
119
120





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







+
+
+
+
+


        @timeLength = (self.totalSamples / self.sampleRate).seconds
      else
        perLoop : Int64 = self.samplesPerLoop.to_i64 * (loops - 1)
        @timeLength = ((self.totalSamples.to_i64 + perLoop) / self.sampleRate).seconds
        @timeLength = @timeLength.not_nil! + Benben.config.fadeoutSeconds.seconds
      end
    end

    def decode(dest : Array(Float32)) : Int32
      # TODO
      raise NotImplementedError.new("Use a Yuno::VgmPlayer directly for now")
    end
  end
end
Changes to src/audio-formats/vorbisfile.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
33
34
35
36
37
38
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







-














-
+







#### 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 "./ogg"
require "./playablefile"
require "./bindings/vorbis"

####
#### Simple wrapper for Vorbis files.
####

module Benben
  # A simple wrapper for Vorbis files.
  class VorbisFile < PlayableFile
    VORBIS_HEADER = "vorbis".to_slice
    VORBIS_COMMENT_PACKET_TYPE = 0x03

    @io : IO?
    @demuxer : OggDemuxer?
    @demuxer : RemiAudio::Demuxers::Ogg?
    @decoder : Vorbis::Decoder?
    @channels : UInt8 = 0u8
    @tagPacket : Bytes = Bytes.new(0)
    @sampleRate : UInt32 = 0u32
    @totalSamples : UInt64 = 0u64
    @mutex : Mutex = Mutex.new

55
56
57
58
59
60
61
62

63
64
65
66
67
68
69
54
55
56
57
58
59
60

61
62
63
64
65
66
67
68







-
+







      RemiLib.assert(@demuxer.nil?)
      if @io.nil?
        @io = File.open(@filename, "rb")
      else
        @io.not_nil!.close
        @io = File.open(@filename, "rb")
      end
      @demuxer = OggDemuxer.new(@io.not_nil!, UInt16::MAX)
      @demuxer = RemiAudio::Demuxers::Ogg.new(@io.not_nil!, UInt16::MAX)
    end

    private def startDecoder : Nil
      @mutex.synchronize do
        # Consistency check
        RemiLib.assert(@demuxer.nil?)
        RemiLib.assert(@decoder.nil?)
121
122
123
124
125
126
127
128

129
130
131
132
133
134
135
120
121
122
123
124
125
126

127
128
129
130
131
132
133
134







-
+







    end

    def totalSamples : UInt64
      ensureFile
      @totalSamples
    end

    def demuxer : OggDemuxer
    def demuxer : RemiAudio::Demuxers::Ogg
      ensureFile
      @demuxer.not_nil!
    end

    def decoder : Vorbis::Decoder
      ensureFile
      @decoder.not_nil!
158
159
160
161
162
163
164




165
166



157
158
159
160
161
162
163
164
165
166
167


168
169
170







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

    def decode(dest : Array(Float32)) : Int32
      # TODO
      raise NotImplementedError.new("Use decoder directly for now")
  end
end
    end
  end
end
Added src/audio-formats/wavpackfile.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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#### 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 "./playablefile"

####
#### Simple wrapper for WavPack files.
####

module Benben
  class WavPackFile < PlayableFile
    @decoder : RAWavPack::Decoder?

    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
      if info = RAWavPack::Decoder.test(filename)
        info.channels == 2
      else
        false
      end
    end

    def ensureFile : Bool
      if @decoder.nil?
        @decoder = RAWavPack::Decoder.new(@filename,
                                          RAWavPack::OpenFlags::Wvc|RAWavPack::OpenFlags::TwoChanMax|
                                          RAWavPack::OpenFlags::Tags|RAWavPack::OpenFlags::Normalize|
                                          RAWavPack::OpenFlags::DsdAsPcm)
      end
      true
    rescue err : Exception
      RemiLib.log.error("Cannot load WavPack #{@filename}: #{err}")
      false
    end

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

    def sampleRate
      ensureFile
      @decoder.not_nil!.sampleRate
    end

    def channels
      ensureFile
      @decoder.not_nil!.channels
    end

    def wvc?
      ensureFile
      @decoder.not_nil!.wvc?
    end

    def mode
      ensureFile
      @decoder.not_nil!.mode
    end

    def avgBitrate
      ensureFile
      @decoder.not_nil!.avgBitrate
    end

    def totalSamples : UInt64
      ensureFile
      @decoder.not_nil!.totalSamples.to_u64
    end

    def ape
      ensureFile
      @decoder.not_nil!.ape
    end

    @[AlwaysInline]
    def pos : Int64
      ensureFile
      @decoder.not_nil!.pos
    end

    @[AlwaysInline]
    def pos=(value) : Int64
      ensureFile
      @decoder.not_nil!.pos = value
    end

    def decode(dest : Array(Float32)) : Int32
      ensureFile
      @decoder.not_nil!.decode(dest).to_i32!
    end
  end
end
Changes to src/command-line.cr.
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
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







-
-
-
+
-

-

-





-
+










-
+








          str << "Built with Crystal: https://crystal-lang.org".colorize.mode(:bold) << "\n"
          str << "Crystal Libraries Used:\n"
          str << "* YunoSynth v#{Yuno::VERSION}\n"
          str << "* Haematite v#{Haematite::VERSION}\n"
          str << "* libremiliacr v#{RemiLib::VERSION}\n"
          str << "* RemiAudio v#{RemiAudio::VERSION}\n"
          str << "* RemiMpg123 v#{RemiMpg123::VERSION}\n"
          {% unless flag?(:benben_no_port_audio) %}
            str << "* RemiPortAudio v#{RemiPA::VERSION}\n"
          str << "* RACodecs v#{RACodecs::VERSION}\n"
          {% end %}
          str << "* RemiSlang v#{RemiSlang::VERSION}\n"
          str << "* RemiSound v#{RemiSound::VERSION}\n"
          str << "* RemiXmp v#{RemiXmp::VERSION}\n"
          str << "* RemiXspf v#{RemiXspf::VERSION}\n"
          str << "* zstd.cr v#{Zstd::VERSION}\n"
          str << '\n'

          str << "Other Libraries Used:\n"
          {% unless flag?(:benben_no_port_audio) %}
            str << "* #{RemiPA.versionStr}\n"
            str << "* #{RemiAudio::Drivers::PortAudio.versionStr}\n"
          {% end %}
          {% unless flag?(:benben_no_ao) %}
            str << "* libao\n"
          {% end %}
          str << "* libxmp v#{RemiXmp.versionStr}\n"
          str << "* #{Opus.version}\n"
          str << "* #{Vorbis.version}\n"
          str << "* S-Lang #{RemiSlang.version}\n"
          str << "* ZStandard #{Zstd::LIB_VERSION}\n"
          {% unless flag?(:benben_no_pulse_audio) %}
            str << "* libpulse v#{RemiSound.pulseAudioVersion}\n"
            str << "* libpulse v#{RemiAudio::Drivers::PulseAudio.version}\n"
          {% end %}
        end

        STDOUT << str
      {% end %}
    end
  end
Changes to src/common.cr.
12
13
14
15
16
17
18

19
20
21
22
23
24
25
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26







+







#### 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 "libremiliacr"
require "haematite"
require "remiaudio"
require "racodecs"
require "remixmp"
require "yaml"
require "yunosynth"

####
#### Super Early Definitions
####
37
38
39
40
41
42
43
44
45




46
47
48
49
50
51
52
38
39
40
41
42
43
44


45
46
47
48
49
50
51
52
53
54
55







-
-
+
+
+
+







  ### Exceptions and Aliases
  ###

  class BenbenError < Exception
  end

  alias RemiConf     = RemiLib::Config
  alias Qoa          = RemiAudio::Codecs::Qoa
  alias RAFlac       = RemiAudio::Codecs::FLAC
  alias Qoa          = RACodecs::Qoa
  alias RAFlac       = RACodecs::FLAC
  alias RemiMpg123   = RACodecs::MPEG1
  alias RAWavPack    = RACodecs::WavPack
  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

####
105
106
107
108
109
110
111

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







+







require "./audio-formats/opusfile"
require "./audio-formats/vorbisfile"
require "./audio-formats/mpeg1file"
require "./audio-formats/midifile"
require "./audio-formats/vgmfile"
require "./audio-formats/pcmfile"
require "./audio-formats/qoafile"
require "./audio-formats/wavpackfile"

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

module Benben
  ###
219
220
221
222
223
224
225

226
227
228
229
230
231
232
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237







+







    Flac
    Opus
    Vorbis
    Mpeg1
    Midi
    Pcm
    Qoa
    WavPack
    Unknown
  end

  enum ReplayGain
    Disabled
    Mix
    Album
343
344
345
346
347
348
349

350
351
352
353
354
355
356
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362







+







  DEF_SONG_CONFG_OPUS = nil
  DEF_SONG_CONFG_VORBIS = nil
  DEF_SONG_CONFG_MPEG1 = nil
  DEF_SONG_CONFG_FLAC = nil
  DEF_SONG_CONFG_MIDI = nil
  DEF_SONG_CONFG_PCM = nil
  DEF_SONG_CONFG_QOA = nil
  DEF_SONG_CONFG_WAVPACK = nil

  # The name of the default theme.
  DEF_THEME_NAME = "default"

  DEF_MIDI_REVERB_ENABLE = true
  DEF_MIDI_REVERB_TYPE = Haematite::ReverbMode::MVerb
  DEF_MIDI_DISABLE_REMAPPING = false
Changes to src/filemanager.cr.
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
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







-
+











-
+







-
+







      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) }
      playlist = File.open(playlistFile, "r") { |file| RemiAudio::Xspf::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)
        RemiAudio::Xspf::Playlist.read(file, format: RemiAudio::Xspf::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
    private def parseXspf(playlist : RemiAudio::Xspf::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"
Changes to src/main.cr.
254
255
256
257
258
259
260
261

262
263
264
265
266
267
268
254
255
256
257
258
259
260

261
262
263
264
265
266
267
268







-
+







            else
              Benben.config.audioDriver
            end

      # Initialize audio driver (i.e., the backend used for output)
      begin
        Benben.driver = AudioDriver.new(drv)
      rescue err : RemiSound::AudioDeviceError
      rescue err : RemiAudio::Drivers::AudioDeviceError
        RemiLib.log.fatal("Could not initialize sound driver: #{err}")
      end

      # Create a ProgramManager
      Benben.manager = ProgramManager.new

      # Redirect messages
Changes to src/playermanager.cr.
107
108
109
110
111
112
113






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







+
+
+
+
+
+








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

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

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

    def getNextFile
      Benben.fileHandler.unloadCurrent
Changes to src/players/flacplayer.cr.
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
18
19
20
21
22
23
24

25
26

27
28
29
30
31
32
33
34

35
36
37
38
39
40
41







-


-








-







####
#### FLAC File Playback (via RemiAudio and libsamplerate)
####

module Benben
  class FlacPlayer < Player
    protected getter! ctx : FlacFile?
    @bufI32 : Array(Int32)
    @samplesRendered : Int32 = 0
    @sampleFormat : RemiAudio::SampleFormat = RemiAudio::SampleFormat::F32
    @dith : RemiAudio::DSP::Ditherer = RemiAudio::DSP::Ditherer.new
    @tempBuf : Array(Float32) = [] of Float32
    @rva : Float32 = 1.0f32

    def initialize
      Benben.dlog!("FlacPlayer starting up")
      @bufSize = Benben.config.bufferSize.to_u32
      @bufRealSize = @bufSize * 2 # multiplied by 2 for stereo
      @buf = Array(Float32).new(@bufRealSize, 0.0f32)
      @bufI32 = Array(Int32).new(@bufRealSize, 0)
    end

    protected def resetInternalVars : Nil
      super
      @maxLoops.set(Benben.config.maxLoops.to_i32 - 1)
    end

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







-
+

-
+

-
+
-
-
-
-
-
-
-

















-
+

-
+

-
+
-
-
-
-
-
-
-







        raise BenbenError.new("Module error: #{err}\nStack trace: #{err.backtrace.join("\n  => ")}")
      {% else %}
        raise BenbenError.new("Module error: #{err}")
      {% end %}
    end

    protected def renderCb : Tuple(Slice(Float32), Int64)
      rendered = self.ctx.decode(@bufI32)
      rendered = self.ctx.decode(@tempBuf)
      if rendered > 0
        if rendered < @bufI32.size
        if rendered < @tempBuf.size
          # Fill remaining buffer with silence
          @bufI32.fill(0, rendered..)
          @tempBuf.fill(0.0f32, rendered..)
        end

        # Convert samples to Float32
        @bufI32.size.times do |idx|
          @tempBuf[idx] = @dith.ditherOrConvert(@bufI32[idx].as(RemiAudio::Sample),
                                                @sampleFormat,
                                                RemiAudio::SampleFormat::F32).to_f32!
        end
      end

      # Divide by 2 since that's the number of channels we have.
      {renderBufSlice(@tempBuf), rendered.tdiv(2).to_i64!}
    rescue err : Exception
      # On error, just return zero
      {renderBufSlice(@tempBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @buf).to_i32!
      else
        # Not resampling.  Render the audio using our context directly
        @samplesRendered = self.ctx.decode(@bufI32)
        @samplesRendered = self.ctx.decode(@buf)
        if @samplesRendered > 0
          if @samplesRendered < @bufI32.size
          if @samplesRendered < @buf.size
            # Fill remaining buffer with silence
            @bufI32.fill(0, @samplesRendered..)
            @buf.fill(0.0f32, @samplesRendered..)
          end

          # Convert samples to Float32
          @bufI32.size.times do |idx|
            @buf.unsafe_put(idx, @dith.ditherOrConvert(@bufI32.unsafe_fetch(idx).as(RemiAudio::Sample),
                                                       @sampleFormat,
                                                       RemiAudio::SampleFormat::F32).to_f32!)
          end
        end
      end

      # Apply volume
      @bufRealSize.times do |idx|
        @buf.unsafe_put(idx, @buf.unsafe_fetch(idx) * @volume)
Changes to src/players/moduleplayer.cr.
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
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







-













-













-
-
+
-








    # When true, playback is paused.
    protected property paused : Bool = false

    # When true, then the current song has no loop information.
    @songHasNoLoop : Bool = false

    @bufI16 : Array(Int16)
    @neededFadeLoops : Int32
    @fadeCoeff : Float64
    @loopSamples : UInt64 = 0u64
    @fadeoutVolAdjust : Float64 = 1.0 # Initial starting multiplier
    @lastPattern : Int32 = -1

    def initialize
      Benben.dlog!("ModulePlayer starting up")

      # Setup buffers and variables
      @bufSize = Benben.config.bufferSize.to_u32
      @bufRealSize = @bufSize * 2 # multiplied by 2 for stereo
      @buf = Array(Float32).new(@bufRealSize, 0.0f32)
      @bufI16 = Array(Int16).new(@bufRealSize, 0i16)

      # Calculate some things for fadeouts.
      secondsToFade : UInt64 = Benben.config.fadeoutSeconds.to_u64!
      fadeSamples : UInt64 = Benben.config.sampleRate.to_u64! * secondsToFade
      @neededFadeLoops = ((fadeSamples / @bufSize) + 0.5).to_i32!

      # Calculate our fade coefficient.
      targetDB : Float64 = -90.0 # in decibels
      @fadeCoeff = 10.0 ** (0.05 * (targetDB / fadeSamples)) # decibels -> linear
    end

    private macro renderBuffers
      # Render the audio, then process
      @bufI16.fill(0i16)
      self.ctx.decode(@bufI16)
      self.ctx.decode(@buf)
      @bufRealSize.times { |idx| @buf.unsafe_put(idx, @bufI16.unsafe_fetch(idx) * {{ 1.0f32 / 32768.0f32 }}) }
      @effects.process(@buf)
    end

    @[AlwaysInline]
    private def getRealVolume(vol : Int) : Float64
      vol / 100.0
    end
Changes to src/players/mpeg1player.cr.
18
19
20
21
22
23
24
25

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

25
26
27
28
29
30
31
32







-
+







####
#### MPEG-1 File Playback for Layers I, II, and III
####

module Benben
  class Mpeg1Player < Player
    protected getter! ctx : Mpeg1File?
    @samplesRendered : Int64 = 0i64
    @samplesRendered : Int32 = 0
    @lastBitrate : Int32 = 0
    @tempBuf : Array(Float32) = [] of Float32
    @lastBitrateUpdate : Time = Time.unix(0)
    @bitrateMode : LibMpg123::VbrMode = LibMpg123::VbrMode::Cbr

    def initialize
      Benben.dlog!("Mpeg1Player starting up")
100
101
102
103
104
105
106
107

108
109
110
111
112
113
114
100
101
102
103
104
105
106

107
108
109
110
111
112
113
114







-
+







      {renderBufSlice(@tempBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @buf)
        @samplesRendered = resamp.read(@resampRatio, @buf).to_i32
      else
        # Not resampling.  Render the audio using our context directly
        @samplesRendered = self.ctx.decode(@buf)
        if @samplesRendered > 0
          if @samplesRendered < @buf.size
            # Fill remaining buffer with silence
            @buf.fill(0.0f32, @samplesRendered..)
Changes to src/players/pcmplayer.cr.
18
19
20
21
22
23
24
25

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

25
26
27
28
29
30
31
32







-
+







####
#### PCM File Playback
####

module Benben
  class PcmPlayer < Player
    protected getter! ctx : PcmFile?
    @samplesRendered : Int64 = 0i64
    @samplesRendered : Int32 = 0
    @tempBuf : Array(Float32) = [] of Float32

    def initialize
      Benben.dlog!("PcmPlayer starting up")

      # Setup buffers and variables
      @bufSize = Benben.config.bufferSize.to_u32
85
86
87
88
89
90
91
92

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

92
93
94
95
96
97
98
99







-
+







      {renderBufSlice(@tempBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @buf)
        @samplesRendered = resamp.read(@resampRatio, @buf).to_i32
      else
        # Not resampling.  Render the audio using our context directly
        @samplesRendered = self.ctx.decode(@buf)
      end

      # Apply volume
      @bufRealSize.times do |idx|
Changes to src/players/qoaplayer.cr.
18
19
20
21
22
23
24
25

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

25
26
27
28
29
30
31
32







-
+







####
#### QOA File Playback
####

module Benben
  class QoaPlayer < Player
    protected getter! ctx : QoaFile?
    @samplesRendered : Int64 = 0i64
    @samplesRendered : Int32 = 0
    @tempBuf : Array(Float32) = [] of Float32
    @seekFrames : Int64 = 2i64

    def initialize
      Benben.dlog!("QoaPlayer starting up")

      # Setup buffers and variables
89
90
91
92
93
94
95
96

97
98
99
100
101
102
103
89
90
91
92
93
94
95

96
97
98
99
100
101
102
103







-
+







      {renderBufSlice(@tempBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @buf)
        @samplesRendered = resamp.read(@resampRatio, @buf).to_i32
      else
        # Not resampling.  Render the audio using our context directly
        @samplesRendered = self.ctx.decode(@buf)
      end

      # Apply volume
      @bufRealSize.times do |idx|
Added src/players/wavpackplayer.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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#### 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 "remiaudio"

####
#### WavPack File Playback
####

module Benben
  class WavPackPlayer < Player
    protected getter! ctx : WavPackFile?
    @samplesRendered : Int32 = 0
    @lastInfoLineUpdate : Time = Time.unix(0)
    @lastAvgBitrate : Float64 = 0.0
    @tempBuf : Array(Float32) = [] of Float32

    def initialize
      Benben.dlog!("WavPackPlayer starting up")

      # Setup buffers and variables
      @bufSize = Benben.config.bufferSize.to_u32
      @bufRealSize = @bufSize * 2 # multiplied by 2 for stereo
      @buf = Array(Float32).new(@bufRealSize, 0.0f32)
    end

    protected def resetInternalVars : Nil
      super
      @maxLoops.set(Benben.config.maxLoops.to_i32 - 1)
    end

    def play(file : PlayableFile) : Nil
      Benben.dlog!("WavPackPlayer is going to play a file")
      raise "WavPacklayer received something that wasn't a WavPackFile" unless file.is_a?(WavPackFile)

      @ctx = file
      updateInfoLine
      self.state = PlayerState::Frame

      # Reset some variables
      resetInternalVars

      # Set the total number of samples.
      @totalSamples.set(self.ctx.totalSamples)

      # Send settings to other things.
      Benben.ui.apply(Benben.config)
      Benben.config.send(@effects)

      # Setup resampling
      Benben.dlog!("WavPackPlayer: Source sample rate is #{file.sampleRate} Hz")
      if file.sampleRate != Benben.config.sampleRate
        Benben.dlog!("WavPackPlayer: Resampling needed, using #{Benben.config.resampler} resampling")
        @tempBuf = Array(Float32).new(@bufRealSize, 0.0f32)
        initResampler(file.sampleRate, Benben.config.resampler)
      else
        Benben.dlog!("WavPackPlayer: Resampling is not needed")
        @resampler = nil
      end

      Benben.dlog!("WavPackPlayer is ready to render frames")
    rescue err : RemiAudio::RemiAudioError
      {% if flag?(:benben_debug) %}
        raise BenbenError.new("Module error: #{err}\nStack trace: #{err.backtrace.join("\n  => ")}")
      {% else %}
        raise BenbenError.new("Module error: #{err}")
      {% end %}
    end

    protected def renderCb : Tuple(Slice(Float32), Int64)
      # The decoder gives us the number of bytes back, so divide by 4.
      rendered = self.ctx.decode(@tempBuf).tdiv(4)
      if rendered >= 0
        if rendered < @tempBuf.size
          # Fill remaining buffer with silence
          @tempBuf.fill(0.0f32, rendered..)
        end
      end

      # Divide by 2 because because 2 channels
      {renderBufSlice(@tempBuf), rendered.tdiv(2).to_i64!}
    rescue err : Exception
      # On error, just return zero
      {renderBufSlice(@tempBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @buf).to_i32
      else
        # Not resampling.  Render the audio using our context directly
        @samplesRendered = self.ctx.decode(@buf)
        if @samplesRendered > 0
          if @samplesRendered < @buf.size
            # Fill remaining buffer with silence
            @buf.fill(0.0f32, @samplesRendered..)
          end
        end
      end

      # Apply volume
      @bufRealSize.times do |idx|
        @buf.unsafe_put(idx, @buf.unsafe_fetch(idx) * @volume)
      end

      # Process with effects
      @effects.process(@buf)
    end

    @[AlwaysInline]
    private def updateInfoLine : Nil
      file = @ctx.not_nil!
      if file.mode.hybrid?
        if file.mode.wvc?
          self.infoLine = sprintf("WavPack File, hybrid, %0.1f kbps avg (WVC loaded)", file.avgBitrate)
        else
          self.infoLine = sprintf("WavPack File, hybrid, %0.1f kbps avg (WVC not found)", file.avgBitrate)
        end
      elsif file.mode.lossless?
        self.infoLine = sprintf("WavPack File, lossless, %0.1f kbps avg", file.avgBitrate)
      else
        self.infoLine = sprintf("WavPack File, %0.1f kbps avg", file.avgBitrate)
      end
    end

    def playFrame : Bool
      case @state
      in .frame?
        # Render the buffer, update position info, then send the data to the
        # audio device.
        renderBuffers
        @samplesPlayed.set(self.ctx.pos.to_u64!)
        Benben.driver.writeBuffer(@buf)

        # Did we finish?
        if @samplesPlayed.get >= @totalSamples.get
          # Do we loop?
          if @maxLoops.get >= 0 && (@currentLoop.get >= @maxLoops.get)
            self.state = PlayerState::Tails
          else
            self.ctx.pos = 0
            @currentLoop.add(1)
            @samplesPlayed.set(0)
          end
        end

        if (Time.local - @lastInfoLineUpdate).total_seconds >= 1
          if @lastAvgBitrate != ctx.avgBitrate
            @lastAvgBitrate = ctx.avgBitrate
            updateInfoLine
          end
          @lastInfoLineUpdate = Time.local
        end

        true

      in .fadeout?
        raise "WavPack files don't have fadeout capabilities"

      in .tails?
        # Track is not looping, play just a bit longer to add some silence
        # or let some instrument tails play.
        @buf.fill(0.0f32)
        @effects.channelVolL.set(SILENCE)
        @effects.channelVolR.set(SILENCE)
        if @nonLoopingExtraLoops > 0
          @nonLoopingExtraLoops -= 1
          Benben.driver.writeBuffer(@buf)
          true
        else
          self.state = PlayerState::Done
          false
        end

      in .paused?
        @buf.fill(0.0f32)
        @samplesPlayed.set(self.ctx.pos.to_u64!) # Could be changed by seeking
        Benben.driver.writeBuffer(@buf) # Do not cause a buffer underrun with PortAudio
        true

      in .done?
        self.state = PlayerState::Done
        false
      end
    end

    def state=(newState : PlayerState) : Nil
      Benben.dlog!("WavPackPlayer: Changing state to #{newState} (current state: #{@state})")
      @lastState = @state
      @state = newState
    end

    def togglePause : Nil
      if @state.paused?
        self.state = @lastState
      else
        self.state = PlayerState::Paused
      end
    end

    def toggleEq : Nil
      if @effects.toggleEq
        Benben.config.equalizerEnabled = true
        Benben.config.equalizerEnabledFromSongConf = false
      else
        Benben.config.equalizerEnabled = false
        Benben.config.equalizerEnabledFromSongConf = false
      end
    end

    def toggleSoftClipping : Nil
      Benben.config.enableSoftClipping = @effects.toggleSoftClipping
    end

    def toggleStereoEnhancer : Nil
      Benben.config.enableStereoEnhancer = @effects.toggleStereoEnhancer
    end

    def toggleReverb : Nil
      Benben.config.reverbEnabled = @effects.toggleReverb
    end

    def volUp : Float64
      @volume = (@volume + VOL_STEP).clamp(0.0, 3.0).to_f32!
      Benben.dlog!("WavPackPlayer volume changed to #{@volume}")
      @volume.to_f64!
    end

    def volDown : Float64
      @volume = (@volume - VOL_STEP).clamp(0.0, 3.0).to_f32!
      Benben.dlog!("WavPackPlayer volume changed to #{@volume}")
      @volume.to_f64!
    end

    def loopUp : Nil
      unless @maxLoops == Int32::MAX
        wasIndefinite = @maxLoops.get < 0
        @maxLoops.add(1)

        if wasIndefinite
          @currentLoop.set(Math.max(0u32, @maxLoops.get - 1).to_u32)
        end
      end
    end

    def loopDown : Nil
      unless @maxLoops.get < 0
        @maxLoops.sub(1)

        # If the new maxLoopTimes would cause the song to end, just put our
        # current loop as the final loop.
        if @maxLoops.get >= 0 && @currentLoop.get > @maxLoops.get
          @currentLoop.set(Math.max(0, @maxLoops.get - 1).to_u32)
        end
      end
    end

    def seekForward : Nil
      Benben.dlog!("Cur pos: #{self.ctx.pos}, total: #{self.ctx.totalSamples}, want to increase #{Benben.config.seekTime.to_i64! * self.ctx.sampleRate}")
      unless self.ctx.pos + (Benben.config.seekTime.to_i64! * self.ctx.sampleRate) >= self.ctx.totalSamples
        self.ctx.pos += (Benben.config.seekTime.to_i64! * self.ctx.sampleRate)
      end
    end

    def seekBackward : Nil
      if self.ctx.pos - (Benben.config.seekTime.to_i64! * self.ctx.sampleRate) < 0
        self.ctx.pos = 0
      else
        self.ctx.pos -= (Benben.config.seekTime.to_i64! * self.ctx.sampleRate)
      end
    end

    def stop : Nil
      Benben.dlog!("Stopping WavPackPlayer")
      @skipFadeout = true
      self.state = PlayerState::Done
      @ctx.try(&.unload)
    end

    def curFile : PlayableFile?
      @ctx
    end

    def maxLoopTimes : Int32
      @maxLoops.get
    end
  end
end
Changes to src/rendering/flacjob.cr.
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
15
16
17
18
19
20
21


22
23
24
25
26
27
28







-
-







#### along with this program.  If not, see <https://www.gnu.org/licenses/>.

module Benben::Rendering
  # A render job that converts a FLAC file into a WAV/Au.
  class FlacJob < Job
    @ctx : FlacFile
    @sampleFormat : RemiAudio::SampleFormat = RemiAudio::SampleFormat::F32
    @dith : RemiAudio::DSP::Ditherer = RemiAudio::DSP::Ditherer.new
    @bufI32 : Array(Int32) = [] of Int32
    @rva : Float64 = 1.0

    #
    # These are used when resampling
    #
    @tempBuf : Array(Float32) = [] of Float32

64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
62
63
64
65
66
67
68

69
70
71
72
73
74
75







-







      @nonLoopingExtraSamples = 0
    end

    def render(statusChan : ProgressChan) : Nil
      # Setup buffer sizes and other variables
      bigBufSize : Int32 = DEF_RENDER_BUFFER_SIZE * 2
      buf : Array(Float64) = Array(Float64).new(bigBufSize, 0.0) # multiplied by 2 for 2 channels
      @bufI32 = Array(Int32).new(bigBufSize, 0) # multiplied by 2 for 2 channels
      smpIdx : UInt32 = 0
      newMax : Float64 = 0.0
      samplesPlayed : UInt32 = 0
      samplesRendered : Int32 = 0

      # Send settings to other things.
      @settings.send(@reverb)
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
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







-
+

-
+

-
+
-
-
-
-
-
-
-

















-
-
+
+


-
-
+
+
-
-
+
-
-
-
-







    def calcTotalSampleSize : UInt32
      ret = @totalSamples.to_u32
      ret *= 2 if Benben.args["normalize"].called?
      ret
    end

    protected def renderCb : Tuple(Slice(Float32), Int64)
      rendered = @ctx.decoder.renderStereo(@bufI32)
      rendered = @ctx.decoder.decode(@tempBuf)
      if rendered > 0
        if rendered < @bufI32.size
        if rendered < @tempBuf.size
          # Fill remaining buffer with silence
          @bufI32.fill(0, rendered..)
          @tempBuf.fill(0, rendered..)
        end

        # Convert samples to Float32
        @bufI32.size.times do |idx|
          @tempBuf[idx] = @dith.ditherOrConvert(@bufI32[idx].as(RemiAudio::Sample),
                                                @sampleFormat,
                                                RemiAudio::SampleFormat::F32).to_f32!
        end
      end

      # Divide by 2 since that's the number of channels we have.
      {renderBufSlice(@tempBuf), rendered.tdiv(2).to_i64!}
    rescue err : Exception
      # On error, just return zero
      {renderBufSlice(@tempBuf), 0i64}
    end

    # NOTE: Purposely unhygenic
    private macro renderBuffers
      # Render the audio, then process
      if resamp = @resampler
        # We're resampling, so use that to get data
        samplesRendered = resamp.read(@resampRatio, buf).to_i32! * 2
      else
        samplesRendered = @ctx.decoder.renderStereo(@bufI32)
        RemiLib.assert(samplesRendered <= @bufI32.size)
        samplesRendered = @ctx.decoder.decode(buf)
        RemiLib.assert(samplesRendered <= buf.size)

        if samplesRendered > 0
          if samplesRendered < @bufI32.size
            @bufI32.fill(0, samplesRendered..)
          if samplesRendered < buf.size
            puts samplesRendered
          end

            buf.fill(0.0, samplesRendered..)
          @bufI32.size.times do |idx|
            buf.unsafe_put(idx, @dith.ditherOrConvert(@bufI32.unsafe_fetch(idx).as(RemiAudio::Sample),
                                                      @sampleFormat,
                                                      RemiAudio::SampleFormat::F32).to_f64!)
          end
        end
      end

      if samplesRendered > 0
        if @settings.enableStereoEnhancer?
          RemiAudio::DSP::StereoEnhancer.process(buf, @settings.stereoEnhancementAmount)
Changes to src/rendering/job.cr.
217
218
219
220
221
222
223

224
225
226
227
228
229
230
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231







+







      in VorbisFile then outputPath
      in Mpeg1File then outputPath
      in FlacFile then outputPath
      in ModuleFile then outputPath
      in PcmFile then outputPath
      in MidiFile then outputPath
      in QoaFile then outputPath
      in WavPackFile then outputPath
      in PlayableFile then raise "Case statement was not updated to take care of files of class #{typeof(pfile)} / #{pfile.class}"
      end
    end

    ###
    ### Sample Conversions/Dithering
    ###
316
317
318
319
320
321
322

317
318
319
320
321
322
323
324







+
require "./modulejob"
require "./flacjob"
require "./vorbisjob"
require "./opusjob"
require "./mpeg1job"
require "./midijob"
require "./qoajob"
require "./wavpackjob"
Changes to src/rendering/modulejob.cr.
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
60
61
62
63
64
65
66

67
68
69
70
71
72
73







-







      raise "maxLoops should not be zero or less" if @maxLoops <= 0
    end

    def render(statusChan : ProgressChan) : Nil
      # Setup buffer sizes and other variables
      bigBufSize : Int32 = DEF_RENDER_BUFFER_SIZE * 2
      buf : Array(Float64) = Array(Float64).new(bigBufSize, 0.0) # multiplied by 2 for 2 channels
      buf16 : Array(Int16) = Array(Int16).new(bigBufSize, 0i16) # multiplied by 2 for 2 channels
      smpIdx : UInt32 = 0
      newMax : Float64 = 0.0
      samplesPlayed : UInt32 = 0
      timesPlayed : UInt32 = 0

      # Calculate our fade coefficient.
      fadeSamples : UInt64 = calcFade
200
201
202
203
204
205
206
207
208


209
210
211
212
213
214
215
216
199
200
201
202
203
204
205


206
207

208
209
210
211
212
213
214







-
-
+
+
-







      end
      totalSamples *= 2 if Benben.args["normalize"].called?
      @totalSampleSize = totalSamples
    end

    # NOTE: Purposely unhygenic
    private macro renderBuffers
      buf16.fill(0i16)
      @ctx.decode(buf16)
      buf.fill(0.0)
      @ctx.decode(buf)
      buf.size.times { |idx| buf.put!(idx, buf16.get!(idx) * {{ 1.0f64 / 32768.0f64 }}) }

      if @settings.enableStereoEnhancer?
        RemiAudio::DSP::StereoEnhancer.process(buf, @settings.stereoEnhancementAmount)
      end

      @eq.process(buf)
      @reverb.process(buf)
Changes to src/rendering/mpeg1job.cr.
24
25
26
27
28
29
30
31

32
33
34
35
36
37
38
24
25
26
27
28
29
30

31
32
33
34
35
36
37
38







-
+







    @renderBuf : Array(Float32) = [] of Float32
    @audioBuf : Array(Float32)
    @audioBuf64 : Array(Float64) = [] of Float64
    @convFn : Proc(Float64, Float32) | Proc(Float64, Float64) | Proc(Float64, Int16) |
      Proc(Float64, Int32) | Proc(Float64, Int64) | Proc(Float64, Int8) | Proc(Float64, UInt8)
    protected getter! stream : AudioFile?
    @samplesPlayed : UInt32 = 0
    @samplesRendered : Int64 = 0i64
    @samplesRendered : Int32 = 0

    def initialize(mpeg1 : Mpeg1File, @inFilename : Path, @outFilename : Path, @doneChan : JobChannel)
      @source = mpeg1
      @ctx = mpeg1
      @totalSamples = @ctx.totalSamples.to_u64
      @settings = Benben.config.dup
      @reverb = Reverb.new(DEF_RENDER_BUFFER_SIZE)
123
124
125
126
127
128
129
130

131
132
133
134
135
136
137
123
124
125
126
127
128
129

130
131
132
133
134
135
136
137







-
+







    end

    @[AlwaysInline]
    private def renderBuffers
      # Render the audio, then process
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @audioBuf)
        @samplesRendered = resamp.read(@resampRatio, @audioBuf).to_i32
      else
        @samplesRendered = @ctx.decode(@audioBuf)
        if @samplesRendered > 0
          if @samplesRendered < @audioBuf.size
            # Fill remaining buffer with silence
            @audioBuf.fill(0.0f32, @samplesRendered..)
          end
Changes to src/rendering/qoajob.cr.
23
24
25
26
27
28
29
30

31
32
33
34
35
36
37
23
24
25
26
27
28
29

30
31
32
33
34
35
36
37







-
+







    @renderBuf : Array(Float32) = [] of Float32
    @audioBuf : Array(Float32)
    @audioBuf64 : Array(Float64) = [] of Float64
    @convFn : Proc(Float64, Float32) | Proc(Float64, Float64) | Proc(Float64, Int16) |
      Proc(Float64, Int32) | Proc(Float64, Int64) | Proc(Float64, Int8) | Proc(Float64, UInt8)
    protected getter! stream : AudioFile?
    @samplesPlayed : UInt32 = 0
    @samplesRendered : Int64 = 0i64
    @samplesRendered : Int32 = 0

    def initialize(qoa : QoaFile, @inFilename : Path, @outFilename : Path, @doneChan : JobChannel)
      @source = qoa
      @ctx = qoa
      @totalSamples = @ctx.totalSamples.to_u64
      @settings = Benben.config.dup
      @reverb = Reverb.new(DEF_RENDER_BUFFER_SIZE)
119
120
121
122
123
124
125
126

127
128
129
130
131
132
133
119
120
121
122
123
124
125

126
127
128
129
130
131
132
133







-
+







    end

    @[AlwaysInline]
    private def renderBuffers
      # Render the audio, then process
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @audioBuf)
        @samplesRendered = resamp.read(@resampRatio, @audioBuf).to_i32
      else
        @samplesRendered = @ctx.decode(@audioBuf)
        if @samplesRendered > 0
          if @samplesRendered < @audioBuf.size
            # Fill remaining buffer with silence
            @audioBuf.fill(0.0f32, @samplesRendered..)
          end
Changes to src/rendering/renderer.cr.
204
205
206
207
208
209
210


211
212
213
214
215
216
217
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219







+
+







          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 QoaFile
          jobs << QoaJob.new(file.as(QoaFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in PcmFile
          raise "Should have been checked earlier"
        in WavPackFile
          jobs << WavPackJob.new(file.as(WavPackFile), Path[Benben.fileHandler.currentFilename], filename, jobChan)
        in PlayableFile
          raise "Unexpectedly received nil PlayableFile"
        end
        Fiber.yield
      end

      jobs
Added src/rendering/wavpackjob.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
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
#### 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 "./oggjob"

module Benben::Rendering
  # A render job that converts an WavPack file into a WAV/Au.
  class WavPackJob < Job
    @ctx : WavPackFile
    @bufSize : Int32
    @bufRealSize : Int32
    @renderBuf : Array(Float32) = [] of Float32
    @audioBuf : Array(Float32)
    @audioBuf64 : Array(Float64) = [] of Float64
    @convFn : Proc(Float64, Float32) | Proc(Float64, Float64) | Proc(Float64, Int16) |
      Proc(Float64, Int32) | Proc(Float64, Int64) | Proc(Float64, Int8) | Proc(Float64, UInt8)
    protected getter! stream : AudioFile?
    @samplesPlayed : UInt32 = 0
    @samplesRendered : Int32 = 0

    def initialize(wavpack : WavPackFile, @inFilename : Path, @outFilename : Path, @doneChan : JobChannel)
      @source = wavpack
      @ctx = wavpack
      @totalSamples = @ctx.totalSamples.to_u64
      @settings = Benben.config.dup
      @reverb = Reverb.new(DEF_RENDER_BUFFER_SIZE)

      @bufSize = DEF_RENDER_BUFFER_SIZE
      @bufRealSize = @bufSize * 2 # multiplied by 2 for stereo
      @audioBuf64 = Array(Float64).new(@bufRealSize, 0.0)
      @audioBuf = Array(Float32).new(@bufRealSize, 0.0f32)

      # Do we need to do resampling?
      if @ctx.sampleRate != @settings.sampleRate
        initResampler(@ctx.sampleRate, @settings.resampler)
        @renderBuf = Array(Float32).new(@bufRealSize, 0.0f32)
      end

      #
      # Some settings need to be setup now so that the Renderer can calculate
      # samples correctly, and simply because they don't need to be done in the
      # render method.
      #

      @settings.maybeApplySongConfig(@inFilename)
      @settings.apply(Benben.args) # Command line overrides everything
      @eq = @settings.makeEQ # Setup the EQ.  We always have an EQ in memory.
      @eq.active = false if @settings.noEQDuringRendering?

      # We don't use this, set it to zero
      @nonLoopingExtraSamples = 0

      # Get the function we'll use to convert the samples.
      @convFn = getConversionFn
    end

    def render(statusChan : ProgressChan) : Nil
      # Send settings to other things.
      @settings.send(@reverb)

      # Get the output stream and start it.
      File.delete(@outFilename) if File.exists?(@outFilename) # This speeds things up???
      @stream = getStream(@outFilename)
      begin
        # Main rendering loop
        until @samplesPlayed > @totalSamples
          # Render the buffer.  This is a purposely unhygenic macro.
          renderBuffers

          # Write samples to the file, mapping to the correct format as-needed.
          statusChan.send(DEF_RENDER_BUFFER_SIZE.to_u32!)
          Fiber.yield
        end
      rescue err : Exception
        @errored = true
        @doneChan.send(RenderingError.new(@outFilename, "Failed to render file: #{err}", err))
        return
      ensure
        stream.close
        @ctx.unload
      end

      @doneChan.send(true)
    end

    # Calculates the total number of samples that this job will produce.  These
    # are stereo samples, so one sample = left and right.  The value is
    # memoized.
    def calcTotalSampleSize : UInt32
      ret = @totalSamples.to_u32
      ret *= 2 if Benben.args["normalize"].called?
      ret
    end

    protected def renderCb : Tuple(Slice(Float32), Int64)
      rendered = @ctx.decode(@renderBuf).tdiv(4) # The decoder gives us the number of bytes back, so divide by 4.
      if rendered >= 0
        if rendered < @renderBuf.size
          @renderBuf.fill(0.0f32, rendered..)
        end
      end

      # Divide by 2 because because 2 channels
      {renderBufSlice(@renderBuf), rendered.tdiv(2).to_i64!}
    rescue err : Exception
      # On error, just return zero
      {renderBufSlice(@renderBuf), 0i64}
    end

    @[AlwaysInline]
    private def renderBuffers
      # Render the audio, then process
      if resamp = @resampler
        # We're resampling, so use that to get data
        @samplesRendered = resamp.read(@resampRatio, @audioBuf).to_i32
      else
        @samplesRendered = @ctx.decode(@audioBuf)
        if @samplesRendered > 0
          if @samplesRendered < @audioBuf.size
            # Fill remaining buffer with silence
            @audioBuf.fill(0.0f32, @samplesRendered..)
          end
        end
      end

      sendToFile
    end

    @[AlwaysInline]
    protected def sendToFile
      if @samplesRendered > 0
        newMax : Float64 = 0.0

        # Convert to Float64
        @audioBuf.size.times do |idx|
          @audioBuf64[idx] = @audioBuf[idx].to_f64!
        end

        if @settings.enableStereoEnhancer?
          RemiAudio::DSP::StereoEnhancer.process(@audioBuf64, @settings.stereoEnhancementAmount)
        end

        @eq.process(@audioBuf64)
        @reverb.process(@audioBuf64)

        if @settings.enableSoftClipping?
          @audioBuf64.size.times do |idx|
            # About -0.3db
            @audioBuf64.put!(idx, RemiAudio::DSP::SoftClipping.process(0.9660508789898133, @audioBuf64.get!(idx)))
          end
        end

        @max = newMax if (newMax = @audioBuf64.max) > @max
        @min = newMax if (newMax = @audioBuf64.min) < @min

        @samplesPlayed += @bufSize
        @audioBuf64.each do |smp|
          self.stream.writeSample(@convFn.call(smp))
        end
      end
      @audioPos = 0
    end

    def generateCue(cue : RemiAudio::Cue, cuePath : Path, idx : Int) : Nil
      cue.addFile do |file|
        # Construct the filename
        file.filename = self.outFilename.relative_to(cuePath).to_s

        tag = ApeTagInfo.new(@ctx)

        # Set the type, add a single track
        file.type = RemiAudio::Cue::File::Type::Wave
        file.addTrack do |trk|
          trk.title = tag.title
          trk.performer = tag.artist
          trk.songwriter = tag.composer
          trk.trackNumber = idx.to_u32 + 1
          trk.setIndex(0, RemiAudio::Cue::Timestamp.new)
          trk.pregap = RemiAudio::Cue::Timestamp.new(0, 2, 0)
        end
      end
    end
  end
end
Changes to src/taginfo.cr.
34
35
36
37
38
39
40

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







+







      in FlacFile then VorbisTagInfo.new(file)
      in OpusFile then VorbisTagInfo.new(file)
      in VorbisFile then VorbisTagInfo.new(file)
      in Mpeg1File then Id3TagInfo.new(file)
      in MidiFile then MidiTagInfo.new(file)
      in PcmFile then PcmTagInfo.new(file)
      in QoaFile then QoaTagInfo.new(file)
      in WavPackFile then ApeTagInfo.new(file)
      in PlayableFile then raise "Forgot to update case statement"
      end
    end
  end

  module ReplayInfoTagsMixin
    @trackGain : Float64?
274
275
276
277
278
279
280















281



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







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
    end
  end

  class QoaTagInfo < TagInfo
    def initialize(file : QoaFile)
    end
  end


  class ApeTagInfo < TagInfo
    property composer : String = ""
    property performer : String = ""

    def initialize(file : WavPackFile)
      data = file.ape
      data["TITLE"]?.try { |str| @title = str.join(", ") }
      data["ARTIST"]?.try { |str| @artist = str.join(", ") }
      data["ALBUM"]?.try { |str| @album = str.join(", ") }
      data["DATE"]?.try { |str| @date = str.join(", ") }
      data["GENRE"]?.try { |str| @genre = str.join(", ") }
      data["COMPOSER"]?.try { |str| @composer = str.join(", ") }
      data["PERFORMER"]?.try { |str| @performer = str.join(", ") }
end
    end
  end
end
Changes to src/uis/orig/tagdisplay.cr.
255
256
257
258
259
260
261













262
263
264
265
266
267
268
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







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







          @tagFields[TAG_FIELD_AUTHOR      ].set("Samples: ", "")
          @tagFields[TAG_FIELD_SYSTEM      ].set("Typ: ", "")
          @tagFields[TAG_FIELD_RELEASE_DATE].set("Channels: ",  "")
          @tagFields[TAG_FIELD_RELEASE_DATE].set("Resampling: ",  "")
        end
        @tagFields[TAG_FIELD_RVA         ].set("RVA: ",          "N/A")

      in ApeTagInfo
        @tagFields[TAG_FIELD_TRACK       ].set("Title: ",        tag.title)
        @tagFields[TAG_FIELD_GAME        ].set("Album: ",        tag.album)
        @tagFields[TAG_FIELD_AUTHOR      ].set("Artist: ",       tag.artist)
        @tagFields[TAG_FIELD_SYSTEM      ].set("Genre: ",        tag.genre)
        @tagFields[TAG_FIELD_RELEASE_DATE].set("Release Date: ", tag.date)
        if !tag.composer.empty?
          @tagFields[TAG_FIELD_CREATOR].set("Composer: ", tag.composer)
        else
          @tagFields[TAG_FIELD_CREATOR].set("Performer: ", tag.performer)
        end
        @tagFields[TAG_FIELD_RVA         ].set("RVA: ",          "N/A")

      in TagInfo, Nil
        @tagFields[TAG_FIELD_TRACK       ].set("Title: ",        "")
        @tagFields[TAG_FIELD_GAME        ].set("Game: ",         "")
        @tagFields[TAG_FIELD_AUTHOR      ].set("Author: ",       "")
        @tagFields[TAG_FIELD_SYSTEM      ].set("System: ",       "")
        @tagFields[TAG_FIELD_RELEASE_DATE].set("Release Date: ", "")
        @tagFields[TAG_FIELD_CREATOR     ].set("VGM Creator: ",  "")