Created
March 12, 2023 22:25
-
-
Save MasonGulu/dbd073e168ab8aa394bd5be04750c5a3 to your computer and use it in GitHub Desktop.
Audio playback library for ComputerCraft
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- Basic audio playback library for CC | |
-- Loads audio from DFPWM | |
-- Maintains a seperate audio playback channel for each attached speaker | |
-- Supports a priority system allowing sounds to be overwritten by higher priority ones | |
-- To setup your audio system | |
-- local audio = require "audio" | |
-- local audioSys = audio.new() | |
-- To start running your audio system | |
-- parallel.waitForAny(audioSys.start, yourFunc) | |
-- To queue up sound on your audio system | |
-- local sound = audioLib.audio("sound.dfpwm") | |
-- you should pre-load your sounds, you can reuse the same sound table | |
-- audioSys.playAudio(sound, 1) | |
-- Copyright 2022 Mason Gulu | |
-- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: | |
-- The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. | |
-- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. | |
-- Don't worry, this is only used for file loading, as dfpwm decoding may take awhile on large files | |
local function doYield() | |
os.queueEvent("fakeEvent") | |
os.pullEvent("fakeEvent") | |
end | |
---@return string | |
local function loadFile(filename) | |
local f = fs.open(filename, "rb") | |
if f then | |
local read = f.readAll() or "" | |
f.close() | |
return read | |
end | |
error(("File %s does not exist."):format(filename)) | |
end | |
local function shallowClone(t) | |
local c = {} | |
for k,v in pairs(t) do | |
c[k] = v | |
end | |
return c | |
end | |
local audioFramerate = 6000 | |
---Load some audio data from a file. | |
---Can yield, loading may take awhile | |
---@param filename string | |
---@param verbose boolean? | |
---@return table | |
local function loadAudio(filename, verbose) | |
if verbose then | |
print(("Loading audio.%s..."):format(filename)) | |
end | |
doYield() | |
local t0 = os.epoch("utc") | |
-- load a dfpwm file, decode it into chunks | |
local rawData = loadFile(filename) | |
---@type table[] | |
local audioData = {} | |
local dfpwm = require("cc.audio.dfpwm") | |
local lastYieldTime = os.epoch('utc') | |
local decoder = dfpwm.make_decoder() | |
for i = 1, #rawData, audioFramerate do | |
audioData[#audioData+1] = decoder(rawData:sub(i, i + audioFramerate)) | |
if os.epoch("utc") > lastYieldTime + 5000 then | |
lastYieldTime = os.epoch("utc") | |
doYield() | |
end | |
end | |
if verbose then | |
print(("Loaded audio.%s in %.2fsec"):format(filename, (os.epoch("utc") - t0) / 1000)) | |
end | |
return audioData | |
end | |
---Create a new audio manager, if speakers is not provided use all connected speakers | |
---@param speakers table<any,string|Speaker>? | |
local function audio(speakers) | |
local self = {} | |
self.defaultPriority = 1 | |
self.defaultVolume = 1 | |
--- Initialize sound system | |
---@alias SpeakerInfo {playAudio: fun(audio: table, volume: integer?): boolean} | |
---@alias BusySpeaker {peripheral: SpeakerInfo, queue: table[], priority: integer, volume: number?, rep: (fun(): boolean)|boolean|number?, original: table?} | |
local speakerList = {} | |
if not speakers then | |
speakerList = {peripheral.find("speaker")} | |
end | |
---@type table<string,SpeakerInfo> | |
local availableSpeakers = {} | |
---@type table<string,BusySpeaker> | |
local busySpeakers = {} | |
for k,v in pairs(speakers or {}) do | |
if type(v) == "string" then | |
availableSpeakers[v] = peripheral.wrap(v) | |
elseif type(v) == "table" then | |
speakerList[#speakerList+1] = v | |
else | |
error(("Invalid speaker: %s"):format(v)) | |
end | |
end | |
for k,v in pairs(speakerList) do | |
availableSpeakers[peripheral.getName(v)] = v | |
end | |
---@param name string | |
local function tickSpeaker(name) | |
local speaker = busySpeakers[name] | |
local sample = table.remove(speaker.queue, 1) | |
if sample then | |
speaker.peripheral.playAudio(sample, speaker.volume) | |
return | |
end | |
if speaker.rep then | |
if type(speaker.rep) == "number" then | |
speaker.queue = shallowClone(speaker.original) | |
speaker.rep = speaker.rep - 1 | |
if speaker.rep > 0 then | |
return tickSpeaker(name) | |
end | |
elseif type(speaker.rep) == "function" then | |
if speaker.rep() then | |
speaker.queue = shallowClone(speaker.original) | |
return tickSpeaker(name) | |
end | |
else -- boolean true fall through | |
speaker.queue = shallowClone(speaker.original) | |
return tickSpeaker(name) | |
end | |
end | |
busySpeakers[name] = nil | |
availableSpeakers[name] = speaker.peripheral | |
end | |
---Find a channel to overwrite with the given audio | |
---@param data table | |
---@param volume number? | |
---@param priority integer? | |
---@return string|nil | |
local function overwriteSpeaker(data, volume, priority) | |
for speakerName,v in pairs(busySpeakers) do | |
if v.priority < priority then | |
-- overwrite this sound | |
for i, sample in ipairs(data) do | |
v.queue[i] = sample | |
end | |
v.volume = volume or self.defaultVolume | |
v.priority = priority or self.defaultPriority | |
tickSpeaker(speakerName) | |
return speakerName | |
end | |
end | |
end | |
---Play some audio, returns speaker it was played on if successful, otherwise returns nothing | |
---@param data table | |
---@param volume number? | |
---@param priority integer? | |
---@param rep (fun(): boolean)|boolean|integer? | |
---@return string|nil speaker | |
function self.playAudio(data, volume, priority, rep) | |
priority = priority or self.defaultPriority | |
volume = volume or self.defaultVolume | |
local speakerName,speaker = next(availableSpeakers) | |
if speaker then | |
availableSpeakers[speakerName] = nil | |
busySpeakers[speakerName] = { | |
peripheral = speaker, | |
queue = shallowClone(data), | |
priority = priority, | |
volume = volume, | |
rep = rep | |
} | |
if rep then | |
busySpeakers[speakerName].original = data | |
end | |
tickSpeaker(speakerName) | |
return speakerName | |
elseif not rep then | |
return overwriteSpeaker(data, volume, priority) | |
end | |
end | |
---Cancel the audio playing on a given speaker | |
---@param speakerName string | |
function self.cancelAudio(speakerName) | |
local info = busySpeakers[speakerName] | |
if info then | |
availableSpeakers[speakerName] = info.peripheral | |
busySpeakers[speakerName] = nil | |
end | |
end | |
---Start ticking the sound system | |
function self.start() | |
while true do | |
local name, speaker = os.pullEvent("speaker_audio_empty") | |
local playingSpeaker = busySpeakers[speaker] | |
if playingSpeaker then | |
tickSpeaker(speaker) | |
end | |
end | |
end | |
return self | |
end | |
return { | |
new = audio, | |
loadAudio = loadAudio | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment