/ src / WAVLib.lua
WAVLib.lua
  1  --[[
  2      CC-WAV-loader - Load any WAV file for use in ComputerCraft
  3      Repository: https://github.com/Kestin4Real/CC-WAV-loader
  4      Description: Load any WAV file for use in ComputerCraft
  5      Language: Lua (100%)
  6  
  7      Copyright (c) 2025 Kestin4Real.
  8      All rights reserved.
  9  
 10      Note: This code is provided without an explicit license. Use at your own discretion.
 11      If you plan to redistribute or modify this work, please give proper attribution to the original repository.
 12      For any formal permissions or licensing clarification, consider contacting the original author.
 13  ]]
 14  --
 15  
 16  ---@class Audio
 17  ---@field channels integer Number of audio channels (1 = mono, 2 = stereo)
 18  ---@field frequency integer Sample rate in Hz (e.g., 44100)
 19  ---@field bytesPerSample integer Bytes per sample (1 = 8-bit, 2 = 16-bit)
 20  ---@field samples integer Total number of samples per channel
 21  ---@field lenght number Total duration in seconds
 22  ---@field channelData table<integer, integer[]> Array of channel data arrays (1-based index)
 23  
 24  local api = {}
 25  
 26  ---@class AudioLoader
 27  ---@field file any The file handle being read
 28  local AudioLoader = {}
 29  AudioLoader.__index = AudioLoader
 30  
 31  ---Create a new AudioLoader instance
 32  ---@return AudioLoader
 33  function AudioLoader.new()
 34      local self = setmetatable({}, AudioLoader)
 35      self.file = nil
 36      return self
 37  end
 38  
 39  ---Reads bytes in big-endian format
 40  ---@param amount integer Number of bytes to read
 41  ---@return integer
 42  function AudioLoader:ReadBytesBig(amount)
 43      local integer = 0
 44      for i = 1, amount do
 45          integer = bit.blshift(integer, 8) + self.file.read()
 46      end
 47      return integer
 48  end
 49  
 50  ---Reads bytes in little-endian format
 51  ---@param amount integer Number of bytes to read
 52  ---@param signed? boolean Whether to interpret as signed integer (default false)
 53  ---@return integer
 54  function AudioLoader:ReadBytesLittle(amount, signed)
 55      if signed == nil then
 56          signed = false
 57      end
 58      local integer = 0
 59      for i = 1, amount do
 60          local byte = self.file.read()
 61          integer = integer + bit.blshift(byte, 8 * (i - 1))
 62          if signed then
 63              if i == amount then
 64                  local sign = bit.brshift(byte, 7) == 1
 65                  if not sign then
 66                      return integer
 67                  end
 68                  integer = integer - bit.blshift(1, 7 + (8 * (i - 1)))
 69                  integer = integer - (math.pow(256, amount) / 2)
 70              end
 71          end
 72      end
 73      return integer
 74  end
 75  
 76  ---Reads bytes as hexadecimal string
 77  ---@param amount integer Number of bytes to read
 78  ---@return string
 79  function AudioLoader:ReadBytesAsHex(amount)
 80      local hex = ""
 81      for i = 1, amount do
 82          hex = hex .. string.format("%02X", self.file.read())
 83      end
 84      return hex
 85  end
 86  
 87  ---Parses the format chunk (fmt )
 88  ---@param audio Audio Audio object to populate
 89  function AudioLoader:ParseFMTchunk(audio) --FMT subchunk parser
 90      if not (self:ReadBytesLittle(4) == 16) then
 91          print("File is in a incorrect format or corrupted 0x04")
 92          return
 93      end --Check for chunk size 16
 94      if not (self:ReadBytesAsHex(2) == "0100") then
 95          print("File is in a incorrect format or corrupted 0x05")
 96          return
 97      end                                             --Check WAVE type 0x01
 98      audio.channels = self:ReadBytesLittle(2)        --number of channels
 99      audio.frequency = self:ReadBytesLittle(4)       --sample frequency
100      self:ReadBytesBig(4)                            --bytes/sec (Garbage Data)
101      self:ReadBytesBig(2)                            --block alignment (Garbage Data)
102      audio.bytesPerSample = self:ReadBytesLittle(2) / 8 --bits per sample
103  end
104  
105  ---Parses the data chunk (data)
106  ---@param audio Audio Audio object to populate
107  function AudioLoader:ParseDATAchunk(audio) --DATA subchunk parser
108      --size of the data chunk
109      audio.samples = self:ReadBytesLittle(4) / audio.channels / audio.bytesPerSample
110      audio.lenght = audio.samples / audio.frequency
111      for s = 0, audio.samples - 1 do
112          local sample = 0
113          for c = 0, audio.channels - 1 do
114              sample = sample + self:ReadBytesLittle(audio.bytesPerSample, audio.bytesPerSample > 1)
115              if audio.bytesPerSample == 1 then
116                  sample = sample - 128
117              end
118          end
119          audio[s] = math.floor(sample / audio.channels / math.max(256 * (audio.bytesPerSample - 1), 1))
120      end
121  end
122  
123  ---Skips non-essential chunks
124  ---@param audio Audio Audio object being parsed
125  function AudioLoader:ParseDUMMYchunk(audio) --Parser for non essential data
126      self:ReadBytesLittle(self:ReadBytesLittle(4))
127  end
128  
129  ---Main loading function
130  ---@param path string Path to WAV file
131  ---@return Audio|nil Loaded audio object or nil on failure
132  function AudioLoader:Load(path)
133      path = shell.resolve(path)
134      if not fs.exists(path) then
135          print("File does not exists")
136          return
137      end
138      if fs.isDir(path) then
139          print("File does not exists")
140          return
141      end
142      self.file = fs.open(path, "rb")
143      local audio = {}
144  
145      if not (self:ReadBytesAsHex(4) == "52494646") then
146          print("File is in a incorrect format or corrupted 0x01")
147          self.file.close()
148          return
149      end                  --Check header for RIFF
150      self:ReadBytesLittle(4) -- size of file
151      if not (self:ReadBytesAsHex(4) == "57415645") then
152          print("File is in a incorrect format or corrupted 0x02")
153          self.file.close()
154          return
155      end --Check WAVE header for WAVE
156  
157      local parsers = {}
158      parsers["666D7420"] = function(audio)
159          self:ParseFMTchunk(audio)
160      end --FMT subchunk parser
161      parsers["64617461"] = function(audio)
162          self:ParseDATAchunk(audio)
163      end --DATA subchunk parser
164  
165      while audio.lenght == nil do
166          local subchunk = self:ReadBytesAsHex(4)
167          if subchunk == nil then
168              print("File is in a incorrect format or corrupted 0x03")
169              self.file.close()
170              return
171          end
172          local parser = parsers[subchunk]
173          if parser == nil then
174              self:ParseDUMMYchunk(audio)
175          else
176              parser(audio)
177          end
178      end
179      self.file.close()
180  
181      return audio
182  end
183  
184  -- Create the public API
185  function api.Load(path)
186      local loader = AudioLoader.new()
187      return loader:Load(path)
188  end
189  
190  return api