From 1cfad31a0d3fb8d8aaac513d9a36e6d8728ec0d5 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Mon, 13 Dec 2021 13:30:43 +0000 Subject: [PATCH 1/3] Separate breaking progress for wired modems This means that if the current player is breaking a cable/wired modem, only the part they're looking at has breaking progress. Closes #355. A mixin is definitely not the cleanest way to do this. There's a couple of alternatives: - CodeChickenLib's approach of overriding the BlockRendererDispatcher instance with a delegating subclasss. One mod doing this is fine, several is Not Great.o - Adding a PR to Forge: I started this, and it's definitely the ideal solution, but any event for this would have a ton of fields and just ended up looking super ugly. --- build.gradle | 28 ++++-- .../mixin/BlockRendererDispatcherMixin.java | 92 +++++++++++++++++++ src/main/resources/computercraft.mixins.json | 13 +++ 3 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 src/main/java/dan200/computercraft/mixin/BlockRendererDispatcherMixin.java create mode 100644 src/main/resources/computercraft.mixins.json diff --git a/build.gradle b/build.gradle index d40ae19ce..ded8570c1 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ } dependencies { classpath 'net.minecraftforge.gradle:ForgeGradle:5.1.+' + classpath "org.spongepowered:mixingradle:0.7.+" classpath 'org.parchmentmc:librarian:1.+' } } @@ -22,6 +23,7 @@ } apply plugin: 'net.minecraftforge.gradle' +apply plugin: "org.spongepowered.mixin" apply plugin: 'org.parchmentmc.librarian.forgegradle' version = mod_version @@ -64,6 +66,8 @@ source sourceSets.main } } + + arg "-mixin.config=computercraft.mixins.json" } client { @@ -109,6 +113,10 @@ accessTransformer file('src/main/resources/META-INF/accesstransformer.cfg') accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') } +mixin { + add sourceSets.main, 'computercraft.mixins.refmap.json' +} + repositories { mavenCentral() maven { @@ -130,6 +138,7 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') checkstyle "com.puppycrawl.tools:checkstyle:8.25" minecraft "net.minecraftforge:forge:${mc_version}-${forge_version}" + annotationProcessor 'org.spongepowered:mixin:0.8.4:processor' compileOnly fg.deobf("mezz.jei:jei-1.16.5:7.7.0.104:api") compileOnly fg.deobf("com.blamejared.crafttweaker:CraftTweaker-1.16.5:7.1.0.313") @@ -149,7 +158,7 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') testModImplementation sourceSets.main.output - cctJavadoc 'cc.tweaked:cct-javadoc:1.4.2' + cctJavadoc 'cc.tweaked:cct-javadoc:1.4.4' } // Compile tasks @@ -181,13 +190,16 @@ task luaJavadoc(type: Javadoc) { jar { manifest { - attributes(["Specification-Title" : "computercraft", - "Specification-Vendor" : "SquidDev", - "Specification-Version" : "1", - "Implementation-Title" : "CC: Tweaked", - "Implementation-Version" : "${mod_version}", - "Implementation-Vendor" : "SquidDev", - "Implementation-Timestamp": new Date().format("yyyy-MM-dd'T'HH:mm:ssZ")]) + attributes([ + "Specification-Title" : "computercraft", + "Specification-Vendor" : "SquidDev", + "Specification-Version" : "1", + "Implementation-Title" : "CC: Tweaked", + "Implementation-Version" : "${mod_version}", + "Implementation-Vendor" : "SquidDev", + "Implementation-Timestamp": new Date().format("yyyy-MM-dd'T'HH:mm:ssZ"), + "MixinConfigs" : "computercraft.mixins.json", + ]) } from configurations.shade.collect { it.isDirectory() ? it : zipTree(it) } diff --git a/src/main/java/dan200/computercraft/mixin/BlockRendererDispatcherMixin.java b/src/main/java/dan200/computercraft/mixin/BlockRendererDispatcherMixin.java new file mode 100644 index 000000000..1d05c2b59 --- /dev/null +++ b/src/main/java/dan200/computercraft/mixin/BlockRendererDispatcherMixin.java @@ -0,0 +1,92 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.mixin; + +import com.mojang.blaze3d.matrix.MatrixStack; +import com.mojang.blaze3d.vertex.IVertexBuilder; +import dan200.computercraft.shared.Registry; +import dan200.computercraft.shared.peripheral.modem.wired.BlockCable; +import dan200.computercraft.shared.peripheral.modem.wired.CableModemVariant; +import dan200.computercraft.shared.peripheral.modem.wired.CableShapes; +import dan200.computercraft.shared.util.WorldUtil; +import net.minecraft.block.BlockState; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.BlockModelRenderer; +import net.minecraft.client.renderer.BlockModelShapes; +import net.minecraft.client.renderer.BlockRendererDispatcher; +import net.minecraft.client.renderer.model.IBakedModel; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.BlockRayTraceResult; +import net.minecraft.util.math.RayTraceResult; +import net.minecraft.world.IBlockDisplayReader; +import net.minecraftforge.client.model.data.IModelData; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.Random; + +/** + * Provides custom block breaking progress for modems, so it only applies to the current part. + * + * @see BlockRendererDispatcher#renderBlockDamage(BlockState, BlockPos, IBlockDisplayReader, MatrixStack, IVertexBuilder, IModelData) + */ +@Mixin( BlockRendererDispatcher.class ) +public class BlockRendererDispatcherMixin +{ + @Shadow + private final Random random; + @Shadow + private final BlockModelShapes blockModelShaper; + @Shadow + private final BlockModelRenderer modelRenderer; + + public BlockRendererDispatcherMixin( Random random, BlockModelShapes blockModelShaper, BlockModelRenderer modelRenderer ) + { + this.random = random; + this.blockModelShaper = blockModelShaper; + this.modelRenderer = modelRenderer; + } + + @Inject( + method = "name=/^renderBlockDamage$/ desc=/IModelData;\\)V$/", + at = @At( "HEAD" ), + cancellable = true, + require = 0 // This isn't critical functionality, so don't worry if we can't apply it. + ) + public void renderBlockDamage( + BlockState state, BlockPos pos, IBlockDisplayReader world, MatrixStack pose, IVertexBuilder buffers, IModelData modelData, + CallbackInfo info + ) + { + // Only apply to cables which have both a cable and modem + if( state.getBlock() != Registry.ModBlocks.CABLE.get() + || !state.getValue( BlockCable.CABLE ) + || state.getValue( BlockCable.MODEM ) == CableModemVariant.None + ) + { + return; + } + + RayTraceResult hit = Minecraft.getInstance().hitResult; + if( hit == null || hit.getType() != RayTraceResult.Type.BLOCK ) return; + BlockPos hitPos = ((BlockRayTraceResult) hit).getBlockPos(); + + if( !hitPos.equals( pos ) ) return; + + info.cancel(); + BlockState newState = WorldUtil.isVecInside( CableShapes.getModemShape( state ), hit.getLocation().subtract( pos.getX(), pos.getY(), pos.getZ() ) ) + ? state.getBlock().defaultBlockState().setValue( BlockCable.MODEM, state.getValue( BlockCable.MODEM ) ) + : state.setValue( BlockCable.MODEM, CableModemVariant.None ); + + IBakedModel model = blockModelShaper.getBlockModel( newState ); + long seed = newState.getSeed( pos ); + modelRenderer.renderModel( world, model, newState, pos, pose, buffers, true, random, seed, OverlayTexture.NO_OVERLAY, modelData ); + } +} diff --git a/src/main/resources/computercraft.mixins.json b/src/main/resources/computercraft.mixins.json new file mode 100644 index 000000000..9da186e3b --- /dev/null +++ b/src/main/resources/computercraft.mixins.json @@ -0,0 +1,13 @@ +{ + "minVersion": "0.8", + "required": true, + "compatibilityLevel": "JAVA_8", + "refmap": "computercraft.mixins.refmap.json", + "package": "dan200.computercraft.mixin", + "client": [ + "BlockRendererDispatcherMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} From e16f66e1286d03aeeffefac6586ad7e1b24061cb Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Mon, 13 Dec 2021 14:30:11 +0000 Subject: [PATCH 2/3] Some bits of rednet cleanup - Remove all the hungrarian notation in variables. Currently leaving the format of rednet messages for now, while I work out whether this counts as part of the public API or not. - Fix the "repeat" program failing with broadcast packets. This was introduced in #900, but I don't think anybody noticed. Will be more relevant when #955 is implemented though. --- .../computercraft/lua/rom/apis/rednet.lua | 236 +++++++++--------- .../data/computercraft/lua/rom/apis/term.lua | 3 +- .../lua/rom/programs/rednet/repeat.lua | 11 +- .../test-rom/spec/apis/rednet_spec.lua | 26 +- 4 files changed, 149 insertions(+), 127 deletions(-) diff --git a/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua b/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua index 83f4bb6ad..3bc55a936 100644 --- a/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua +++ b/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua @@ -29,9 +29,9 @@ CHANNEL_REPEAT = 65533 -- greater or equal to this limit wrap around to 0. MAX_ID_CHANNELS = 65500 -local tReceivedMessages = {} -local tHostnames = {} -local nClearTimer +local received_messages = {} +local hostnames = {} +local prune_received_timer local function id_as_channel(id) return (id or os.getComputerID()) % MAX_ID_CHANNELS @@ -115,10 +115,10 @@ be @{rednet.open|opened} before sending is possible. Assuming the target was in range and also had a correctly opened modem, it may then use @{rednet.receive} to collect the message. -@tparam number nRecipient The ID of the receiving computer. +@tparam number recipient The ID of the receiving computer. @param message The message to send. This should not contain coroutines or functions, as they will be converted to @{nil}. -@tparam[opt] string sProtocol The "protocol" to send this message under. When +@tparam[opt] string protocol The "protocol" to send this message under. When using @{rednet.receive} one can filter to only receive messages sent under a particular protocol. @treturn boolean If this message was successfully sent (i.e. if rednet is @@ -131,41 +131,41 @@ actually _received_. rednet.send(2, "Hello from rednet!") ]] -function send(nRecipient, message, sProtocol) - expect(1, nRecipient, "number") - expect(3, sProtocol, "string", "nil") +function send(recipient, message, protocol) + expect(1, recipient, "number") + expect(3, protocol, "string", "nil") -- Generate a (probably) unique message ID -- We could do other things to guarantee uniqueness, but we really don't need to -- Store it to ensure we don't get our own messages back - local nMessageID = math.random(1, 2147483647) - tReceivedMessages[nMessageID] = os.clock() + 9.5 - if not nClearTimer then nClearTimer = os.startTimer(10) end + local message_id = math.random(1, 2147483647) + received_messages[message_id] = os.clock() + 9.5 + if not prune_received_timer then prune_received_timer = os.startTimer(10) end -- Create the message - local nReplyChannel = id_as_channel() - local tMessage = { - nMessageID = nMessageID, - nRecipient = nRecipient, + local reply_channel = id_as_channel() + local message_wrapper = { + nMessageID = message_id, + nRecipient = recipient, nSender = os.getComputerID(), message = message, - sProtocol = sProtocol, + sProtocol = protocol, } local sent = false - if nRecipient == os.getComputerID() then + if recipient == os.getComputerID() then -- Loopback to ourselves - os.queueEvent("rednet_message", os.getComputerID(), message, sProtocol) + os.queueEvent("rednet_message", os.getComputerID(), message_wrapper, protocol) sent = true else -- Send on all open modems, to the target and to repeaters - if nRecipient ~= CHANNEL_BROADCAST then - nRecipient = id_as_channel(nRecipient) + if recipient ~= CHANNEL_BROADCAST then + recipient = id_as_channel(recipient) end - for _, sModem in ipairs(peripheral.getNames()) do - if isOpen(sModem) then - peripheral.call(sModem, "transmit", nRecipient, nReplyChannel, tMessage) - peripheral.call(sModem, "transmit", CHANNEL_REPEAT, nReplyChannel, tMessage) + for _, modem in ipairs(peripheral.getNames()) do + if isOpen(modem) then + peripheral.call(modem, "transmit", recipient, reply_channel, message_wrapper) + peripheral.call(modem, "transmit", CHANNEL_REPEAT, reply_channel, message_wrapper) sent = true end end @@ -179,23 +179,23 @@ end -- -- @param message The message to send. This should not contain coroutines or -- functions, as they will be converted to @{nil}. --- @tparam[opt] string sProtocol The "protocol" to send this message under. When +-- @tparam[opt] string protocol The "protocol" to send this message under. When -- using @{rednet.receive} one can filter to only receive messages sent under a -- particular protocol. -- @see rednet.receive -- @changed 1.6 Added protocol parameter. -function broadcast(message, sProtocol) - expect(2, sProtocol, "string", "nil") - send(CHANNEL_BROADCAST, message, sProtocol) +function broadcast(message, protocol) + expect(2, protocol, "string", "nil") + send(CHANNEL_BROADCAST, message, protocol) end --[[- Wait for a rednet message to be received, or until `nTimeout` seconds have elapsed. -@tparam[opt] string sProtocolFilter The protocol the received message must be +@tparam[opt] string protocol_filter The protocol the received message must be sent with. If specified, any messages not sent under this protocol will be discarded. -@tparam[opt] number nTimeout The number of seconds to wait if no message is +@tparam[opt] number timeout The number of seconds to wait if no message is received. @treturn[1] number The computer which sent this message @return[1] The received message @@ -227,34 +227,34 @@ received. print(message) ]] -function receive(sProtocolFilter, nTimeout) +function receive(protocol_filter, timeout) -- The parameters used to be ( nTimeout ), detect this case for backwards compatibility - if type(sProtocolFilter) == "number" and nTimeout == nil then - sProtocolFilter, nTimeout = nil, sProtocolFilter + if type(protocol_filter) == "number" and timeout == nil then + protocol_filter, timeout = nil, protocol_filter end - expect(1, sProtocolFilter, "string", "nil") - expect(2, nTimeout, "number", "nil") + expect(1, protocol_filter, "string", "nil") + expect(2, timeout, "number", "nil") -- Start the timer local timer = nil - local sFilter = nil - if nTimeout then - timer = os.startTimer(nTimeout) - sFilter = nil + local event_filter = nil + if timeout then + timer = os.startTimer(timeout) + event_filter = nil else - sFilter = "rednet_message" + event_filter = "rednet_message" end -- Wait for events while true do - local sEvent, p1, p2, p3 = os.pullEvent(sFilter) - if sEvent == "rednet_message" then + local event, p1, p2, p3 = os.pullEvent(event_filter) + if event == "rednet_message" then -- Return the first matching rednet_message - local nSenderID, message, sProtocol = p1, p2, p3 - if sProtocolFilter == nil or sProtocol == sProtocolFilter then - return nSenderID, message, sProtocol + local sender_id, message, protocol = p1, p2, p3 + if protocol_filter == nil or protocol == protocol_filter then + return sender_id, message, protocol end - elseif sEvent == "timer" then + elseif event == "timer" then -- Return nil if we timeout if p1 == timer then return nil @@ -276,34 +276,34 @@ end -- "registering" themselves before doing so (eg while offline or part of a -- different network). -- --- @tparam string sProtocol The protocol this computer provides. --- @tparam string sHostname The name this protocol exposes for the given protocol. +-- @tparam string protocol The protocol this computer provides. +-- @tparam string hostname The name this protocol exposes for the given protocol. -- @throws If trying to register a hostname which is reserved, or currently in use. -- @see rednet.unhost -- @see rednet.lookup -- @since 1.6 -function host(sProtocol, sHostname) - expect(1, sProtocol, "string") - expect(2, sHostname, "string") - if sHostname == "localhost" then +function host(protocol, hostname) + expect(1, protocol, "string") + expect(2, hostname, "string") + if hostname == "localhost" then error("Reserved hostname", 2) end - if tHostnames[sProtocol] ~= sHostname then - if lookup(sProtocol, sHostname) ~= nil then + if hostnames[protocol] ~= hostname then + if lookup(protocol, hostname) ~= nil then error("Hostname in use", 2) end - tHostnames[sProtocol] = sHostname + hostnames[protocol] = hostname end end --- Stop @{rednet.host|hosting} a specific protocol, meaning it will no longer -- respond to @{rednet.lookup} requests. -- --- @tparam string sProtocol The protocol to unregister your self from. +-- @tparam string protocol The protocol to unregister your self from. -- @since 1.6 -function unhost(sProtocol) - expect(1, sProtocol, "string") - tHostnames[sProtocol] = nil +function unhost(protocol) + expect(1, protocol, "string") + hostnames[protocol] = nil end --- Search the local rednet network for systems @{rednet.host|hosting} the @@ -313,36 +313,36 @@ end -- If a hostname is specified, only one ID will be returned (assuming an exact -- match is found). -- --- @tparam string sProtocol The protocol to search for. --- @tparam[opt] string sHostname The hostname to search for. +-- @tparam string protocol The protocol to search for. +-- @tparam[opt] string hostname The hostname to search for. -- -- @treturn[1] { number }|nil A list of computer IDs hosting the given -- protocol, or @{nil} if none exist. -- @treturn[2] number|nil The computer ID with the provided hostname and protocol, -- or @{nil} if none exists. -- @since 1.6 -function lookup(sProtocol, sHostname) - expect(1, sProtocol, "string") - expect(2, sHostname, "string", "nil") +function lookup(protocol, hostname) + expect(1, protocol, "string") + expect(2, hostname, "string", "nil") -- Build list of host IDs - local tResults = nil - if sHostname == nil then - tResults = {} + local results = nil + if hostname == nil then + results = {} end -- Check localhost first - if tHostnames[sProtocol] then - if sHostname == nil then - table.insert(tResults, os.getComputerID()) - elseif sHostname == "localhost" or sHostname == tHostnames[sProtocol] then + if hostnames[protocol] then + if hostname == nil then + table.insert(results, os.getComputerID()) + elseif hostname == "localhost" or hostname == hostnames[protocol] then return os.getComputerID() end end if not isOpen() then - if tResults then - return table.unpack(tResults) + if results then + return table.unpack(results) end return nil end @@ -350,8 +350,8 @@ function lookup(sProtocol, sHostname) -- Broadcast a lookup packet broadcast({ sType = "lookup", - sProtocol = sProtocol, - sHostname = sHostname, + sProtocol = protocol, + sHostname = hostname, }, "dns") -- Start a timer @@ -362,30 +362,28 @@ function lookup(sProtocol, sHostname) local event, p1, p2, p3 = os.pullEvent() if event == "rednet_message" then -- Got a rednet message, check if it's the response to our request - local nSenderID, tMessage, sMessageProtocol = p1, p2, p3 - if sMessageProtocol == "dns" and type(tMessage) == "table" and tMessage.sType == "lookup response" then - if tMessage.sProtocol == sProtocol then - if sHostname == nil then - table.insert(tResults, nSenderID) - elseif tMessage.sHostname == sHostname then - return nSenderID + local sender_id, message, message_protocol = p1, p2, p3 + if message_protocol == "dns" and type(message) == "table" and message.sType == "lookup response" then + if message.sProtocol == protocol then + if hostname == nil then + table.insert(results, sender_id) + elseif message.sHostname == hostname then + return sender_id end end end - else + elseif event == "timer" and p1 == timer then -- Got a timer event, check it's the end of our timeout - if p1 == timer then - break - end + break end end - if tResults then - return table.unpack(tResults) + if results then + return table.unpack(results) end return nil end -local bRunning = false +local started = false --- Listen for modem messages and converts them into rednet messages, which may -- then be @{receive|received}. @@ -393,51 +391,51 @@ local bRunning = false -- This is automatically started in the background on computer startup, and -- should not be called manually. function run() - if bRunning then + if started then error("rednet is already running", 2) end - bRunning = true + started = true - while bRunning do - local sEvent, p1, p2, p3, p4 = os.pullEventRaw() - if sEvent == "modem_message" then + while true do + local event, p1, p2, p3, p4 = os.pullEventRaw() + if event == "modem_message" then -- Got a modem message, process it and add it to the rednet event queue - local sModem, nChannel, nReplyChannel, tMessage = p1, p2, p3, p4 - if nChannel == id_as_channel() or nChannel == CHANNEL_BROADCAST then - if type(tMessage) == "table" and type(tMessage.nMessageID) == "number" - and tMessage.nMessageID == tMessage.nMessageID and not tReceivedMessages[tMessage.nMessageID] - and ((tMessage.nRecipient and tMessage.nRecipient == os.getComputerID()) or nChannel == CHANNEL_BROADCAST) - and isOpen(sModem) + local modem, channel, reply_channel, message = p1, p2, p3, p4 + if channel == id_as_channel() or channel == CHANNEL_BROADCAST then + if type(message) == "table" and type(message.nMessageID) == "number" + and message.nMessageID == message.nMessageID and not received_messages[message.nMessageID] + and ((message.nRecipient and message.nRecipient == os.getComputerID()) or channel == CHANNEL_BROADCAST) + and isOpen(modem) then - tReceivedMessages[tMessage.nMessageID] = os.clock() + 9.5 - if not nClearTimer then nClearTimer = os.startTimer(10) end - os.queueEvent("rednet_message", tMessage.nSender or nReplyChannel, tMessage.message, tMessage.sProtocol) + received_messages[message.nMessageID] = os.clock() + 9.5 + if not prune_received_timer then prune_received_timer = os.startTimer(10) end + os.queueEvent("rednet_message", message.nSender or reply_channel, message.message, message.sProtocol) end end - elseif sEvent == "rednet_message" then + elseif event == "rednet_message" then -- Got a rednet message (queued from above), respond to dns lookup - local nSenderID, tMessage, sProtocol = p1, p2, p3 - if sProtocol == "dns" and type(tMessage) == "table" and tMessage.sType == "lookup" then - local sHostname = tHostnames[tMessage.sProtocol] - if sHostname ~= nil and (tMessage.sHostname == nil or tMessage.sHostname == sHostname) then - rednet.send(nSenderID, { + local sender, message, protocol = p1, p2, p3 + if protocol == "dns" and type(message) == "table" and message.sType == "lookup" then + local hostname = hostnames[message.sProtocol] + if hostname ~= nil and (message.sHostname == nil or message.sHostname == hostname) then + send(sender, { sType = "lookup response", - sHostname = sHostname, - sProtocol = tMessage.sProtocol, + sHostname = hostname, + sProtocol = message.sProtocol, }, "dns") end end - elseif sEvent == "timer" and p1 == nClearTimer then - -- Got a timer event, use it to clear the event queue - nClearTimer = nil - local nNow, bHasMore = os.clock(), nil - for nMessageID, nDeadline in pairs(tReceivedMessages) do - if nDeadline <= nNow then tReceivedMessages[nMessageID] = nil - else bHasMore = true end + elseif event == "timer" and p1 == prune_received_timer then + -- Got a timer event, use it to prune the set of received messages + prune_received_timer = nil + local now, has_more = os.clock(), nil + for message_id, deadline in pairs(received_messages) do + if deadline <= now then received_messages[message_id] = nil + else has_more = true end end - nClearTimer = bHasMore and os.startTimer(10) + prune_received_timer = has_more and os.startTimer(10) end end end diff --git a/src/main/resources/data/computercraft/lua/rom/apis/term.lua b/src/main/resources/data/computercraft/lua/rom/apis/term.lua index 2def0eb50..125d7c408 100644 --- a/src/main/resources/data/computercraft/lua/rom/apis/term.lua +++ b/src/main/resources/data/computercraft/lua/rom/apis/term.lua @@ -59,7 +59,8 @@ end -- @treturn Redirect The current terminal redirect -- @since 1.6 -- @usage --- Create a new @{window} which draws to the current redirect target +-- Create a new @{window} which draws to the current redirect target. +-- -- window.create(term.current(), 1, 1, 10, 10) term.current = function() return redirectTarget diff --git a/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua b/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua index c5ed89984..e1e0b9340 100644 --- a/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua +++ b/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua @@ -14,10 +14,6 @@ else print(#tModems .. " modems found.") end -local function idAsChannel(id) - return (id or os.getComputerID()) % rednet.MAX_ID_CHANNELS -end - local function open(nChannel) for n = 1, #tModems do local sModem = tModems[n] @@ -53,11 +49,16 @@ local ok, error = pcall(function() tReceivedMessages[tMessage.nMessageID] = true tReceivedMessageTimeouts[os.startTimer(30)] = tMessage.nMessageID + local recipient_channel = tMessage.nRecipient + if tMessage.nRecipient ~= rednet.CHANNEL_BROADCAST then + recipient_channel = recipient_channel % rednet.MAX_ID_CHANNELS + end + -- Send on all other open modems, to the target and to other repeaters for n = 1, #tModems do local sOtherModem = tModems[n] peripheral.call(sOtherModem, "transmit", rednet.CHANNEL_REPEAT, nReplyChannel, tMessage) - peripheral.call(sOtherModem, "transmit", idAsChannel(tMessage.nRecipient), nReplyChannel, tMessage) + peripheral.call(sOtherModem, "transmit", recipient_channel, nReplyChannel, tMessage) end -- Log the event diff --git a/src/test/resources/test-rom/spec/apis/rednet_spec.lua b/src/test/resources/test-rom/spec/apis/rednet_spec.lua index de5a85b60..35cb4c1df 100644 --- a/src/test/resources/test-rom/spec/apis/rednet_spec.lua +++ b/src/test/resources/test-rom/spec/apis/rednet_spec.lua @@ -88,6 +88,7 @@ describe("The rednet library", function() local fake_computer = require "support.fake_computer" local debugx = require "support.debug_ext" + local function dawdle() while true do coroutine.yield() end end local function computer_with_rednet(id, fn, options) local computer = fake_computer.make_computer(id, function(env) local fns = { env.rednet.run } @@ -105,6 +106,10 @@ describe("The rednet library", function() end end + if options and options.host then + env.rednet.host("some_protocol", "host_" .. id) + end + return parallel.waitForAny(table.unpack(fns)) end) local modem = fake_computer.add_modem(computer, "back") @@ -203,8 +208,8 @@ describe("The rednet library", function() env.sleep(10) -- Ensure our pending message store is empty. Bit ugly to prod internals, but there's no other way. - expect(debugx.getupvalue(rednet.run, "tReceivedMessages")):same({}) - expect(debugx.getupvalue(rednet.run, "nClearTimer")):eq(nil) + expect(debugx.getupvalue(rednet.run, "received_messages")):same({}) + expect(debugx.getupvalue(rednet.run, "prune_received_timer")):eq(nil) end, { open = true }) local computer_3, modem_3 = computer_with_rednet(3, nil, { open = true, rep = true }) @@ -222,5 +227,22 @@ describe("The rednet library", function() fake_computer.advance_all(computers, 10) fake_computer.run_all(computers, { computer_1, computer_2 }) end) + + it("handles lookups between computers with massive IDs", function() + local id_1, id_3 = 24283947, 93428798 + local computer_1, modem_1 = computer_with_rednet(id_1, function(rednet) + local ids = { rednet.lookup("some_protocol") } + expect(ids):same { id_3 } + end, { open = true }) + local computer_2, modem_2 = computer_with_rednet(2, nil, { open = true, rep = true }) + local computer_3, modem_3 = computer_with_rednet(id_3, dawdle, { open = true, host = true }) + fake_computer.add_modem_edge(modem_1, modem_2) + fake_computer.add_modem_edge(modem_2, modem_3) + + local computers = { computer_1, computer_2, computer_3 } + fake_computer.run_all(computers, false) + fake_computer.advance_all(computers, 3) + fake_computer.run_all(computers, { computer_1 }) + end) end) end) From b048b6666d652dafc4614b086782f956de58d377 Mon Sep 17 00:00:00 2001 From: Jonathan Coates Date: Mon, 13 Dec 2021 22:56:59 +0000 Subject: [PATCH 3/3] Add arbitrary audio support to speakers (#982) Speakers can now play arbitrary PCM audio, sampled at 48kHz and with a resolution of 8 bits. Programs can build up buffers of audio locally, play it using `speaker.playAudio`, where it is encoded to DFPWM, sent across the network, decoded, and played on the client. `speaker.playAudio` may return false when a chunk of audio has been submitted but not yet sent to the client. In this case, the program should wait for a speaker_audio_empty event and try again, repeating until it works. While the API is a little odd, this gives us fantastic flexibility (we can play arbitrary streams of audio) while still being resilient in the presence of server lag (either TPS or on the computer thread). Some other notes: - There is a significant buffer on both the client and server, which means that sound take several seconds to finish after playing has started. One can force it to be stopped playing with the new `speaker.stop` call. - This also adds a `cc.audio.dfpwm` module, which allows encoding and decoding DFPWM1a audio files. - I spent so long writing the documentation for this. Who knows if it'll be helpful! --- doc/events/speaker_audio_empty.md | 27 ++ doc/guides/speaker_audio.md | 200 +++++++++ gradle.properties | 2 +- illuaminate.sexp | 6 +- .../api/peripheral/GenericPeripheral.java | 2 +- .../api/peripheral/PeripheralType.java | 10 +- .../computercraft/client/ClientHooks.java | 3 +- .../computercraft/client/SoundManager.java | 85 ---- .../client/sound/DfpwmStream.java | 125 ++++++ .../client/sound/SpeakerInstance.java | 81 ++++ .../client/sound/SpeakerManager.java | 58 +++ .../client/sound/SpeakerSound.java | 54 +++ .../WebsocketCompressionHandler.java | 2 +- .../shared/network/NetworkHandler.java | 9 +- .../client/SpeakerAudioClientMessage.java | 69 ++++ .../client/SpeakerMoveClientMessage.java | 4 +- .../client/SpeakerPlayClientMessage.java | 4 +- .../client/SpeakerStopClientMessage.java | 4 +- .../commandblock/CommandBlockPeripheral.java | 2 +- .../shared/peripheral/speaker/DfpwmState.java | 117 ++++++ .../peripheral/speaker/SpeakerPeripheral.java | 382 +++++++++++++----- .../peripheral/speaker/TileSpeaker.java | 11 +- .../speaker/UpgradeSpeakerPeripheral.java | 16 +- .../pocket/peripherals/PocketSpeaker.java | 2 +- .../peripherals/PocketSpeakerPeripheral.java | 3 + .../shared/turtle/upgrades/TurtleSpeaker.java | 1 + src/main/resources/META-INF/mods.toml | 2 +- .../assets/computercraft/sounds.json | 10 + .../assets/computercraft/sounds/empty.ogg | Bin 0 -> 3353 bytes .../computercraft/lua/rom/help/speaker.md | 5 + .../lua/rom/modules/main/cc/audio/dfpwm.lua | 228 +++++++++++ .../lua/rom/programs/fun/speaker.lua | 62 +++ .../data/computercraft/lua/rom/startup.lua | 12 + .../peripheral/speaker/DfpwmStateTest.java | 39 ++ .../client/sound/DfpwmStreamTest.java | 40 ++ src/test/resources/test-rom/mcfly.lua | 42 +- .../spec/modules/cc/audio/dfpwm_spec.lua | 26 ++ 37 files changed, 1507 insertions(+), 238 deletions(-) create mode 100644 doc/events/speaker_audio_empty.md create mode 100644 doc/guides/speaker_audio.md delete mode 100644 src/main/java/dan200/computercraft/client/SoundManager.java create mode 100644 src/main/java/dan200/computercraft/client/sound/DfpwmStream.java create mode 100644 src/main/java/dan200/computercraft/client/sound/SpeakerInstance.java create mode 100644 src/main/java/dan200/computercraft/client/sound/SpeakerManager.java create mode 100644 src/main/java/dan200/computercraft/client/sound/SpeakerSound.java create mode 100644 src/main/java/dan200/computercraft/shared/network/client/SpeakerAudioClientMessage.java create mode 100644 src/main/java/dan200/computercraft/shared/peripheral/speaker/DfpwmState.java create mode 100644 src/main/resources/assets/computercraft/sounds.json create mode 100644 src/main/resources/assets/computercraft/sounds/empty.ogg create mode 100644 src/main/resources/data/computercraft/lua/rom/help/speaker.md create mode 100644 src/main/resources/data/computercraft/lua/rom/modules/main/cc/audio/dfpwm.lua create mode 100644 src/main/resources/data/computercraft/lua/rom/programs/fun/speaker.lua create mode 100644 src/test/java/dan200/computercraft/shared/peripheral/speaker/DfpwmStateTest.java create mode 100644 src/test/kotlin/dan200/computercraft/client/sound/DfpwmStreamTest.java create mode 100644 src/test/resources/test-rom/spec/modules/cc/audio/dfpwm_spec.lua diff --git a/doc/events/speaker_audio_empty.md b/doc/events/speaker_audio_empty.md new file mode 100644 index 000000000..69b9a5719 --- /dev/null +++ b/doc/events/speaker_audio_empty.md @@ -0,0 +1,27 @@ +--- +module: [kind=event] speaker_audio_empty +see: speaker.playAudio To play audio using the speaker +--- + +## Return Values +1. @{string}: The event name. +2. @{string}: The name of the speaker which is available to play more audio. + + +## Example +This uses @{io.lines} to read audio data in blocks of 16KiB from "example_song.dfpwm", and then attempts to play it +using @{speaker.playAudio}. If the speaker's buffer is full, it waits for an event and tries again. + +```lua +local dfpwm = require("cc.audio.dfpwm") +local speaker = peripheral.find("speaker") + +local decoder = dfpwm.make_decoder() +for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + local buffer = decoder(chunk) + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` diff --git a/doc/guides/speaker_audio.md b/doc/guides/speaker_audio.md new file mode 100644 index 000000000..a87c1d384 --- /dev/null +++ b/doc/guides/speaker_audio.md @@ -0,0 +1,200 @@ +--- +module: [kind=guide] speaker_audio +see: speaker.playAudio Play PCM audio using a speaker. +see: cc.audio.dfpwm Provides utilities for encoding and decoding DFPWM files. +--- + +# Playing audio with speakers +CC: Tweaked's speaker peripheral provides a powerful way to play any audio you like with the @{speaker.playAudio} +method. However, for people unfamiliar with digital audio, it's not the most intuitive thing to use. This guide provides +an introduction to digital audio, demonstrates how to play music with CC: Tweaked's speakers, and then briefly discusses +the more complex topic of audio processing. + +## A short introduction to digital audio +When sound is recorded it is captured as an analogue signal, effectively the electrical version of a sound +wave. However, this signal is continuous, and so can't be used directly by a computer. Instead, we measure (or *sample*) +the amplitude of the wave many times a second and then *quantise* that amplitude, rounding it to the nearest +representable value. + +This representation of sound - a long, uniformally sampled list of amplitudes is referred to as [Pulse-code +Modulation][PCM] (PCM). PCM can be thought of as the "standard" audio format, as it's incredibly easy to work with. For +instance, to mix two pieces of audio together, you can just samples from the two tracks together and take the average. + +CC: Tweaked's speakers also work with PCM audio. It plays back 48,000 samples a second, where each sample is an integer +between -128 and 127. This is more commonly referred to as 48kHz and an 8-bit resolution. + +Let's now look at a quick example. We're going to generate a [Sine Wave] at 220Hz, which sounds like a low monotonous +hum. First we wrap our speaker peripheral, and then we fill a table (also referred to as a *buffer*) with 128×1024 +samples - this is the maximum number of samples a speaker can accept in one go. + +In order to fill this buffer, we need to do a little maths. We want to play 220 sine waves each second, where each sine +wave completes a full oscillation in 2π "units". This means one seconds worth of audio is 2×π×220 "units" long. We then +need to split this into 48k samples, basically meaning for each sample we move 2×π×220/48k "along" the sine curve. + +```lua {data-peripheral=speaker} +local speaker = peripheral.find("speaker") + +local buffer = {} +local t, dt = 0, 2 * math.pi * 220 / 48000 +for i = 1, 128 * 1024 do + buffer[i] = math.floor(math.sin(t) * 127) + t = (t + dt) % (math.pi * 2) +end + +speaker.playAudio(buffer) +``` + +## Streaming audio +You might notice that the above snippet only generates a short bit of audio - 2.7s seconds to be precise. While we could +try increasing the number of loop iterations, we'll get an error when we try to play it through the speaker: the sound +buffer is too large for it to handle. + +Our 2.7 seconds of audio is stored in a table with over 130 _thousand_ elements. If we wanted to play a full minute of +sine waves (and why wouldn't you?), you'd need a table with almost 3 _million_. Suddenly you find these numbers adding +up very quickly, and these tables take up more and more memory. + +Instead of building our entire song (well, sine wave) in one go, we can produce it in small batches, each of which get +passed off to @{speaker.playAudio} when the time is right. This allows us to build a _stream_ of audio, where we read +chunks of audio one at a time (either from a file or a tone generator like above), do some optional processing to each +one, and then play them. + +Let's adapt our example from above to do that instead. + +```lua {data-peripheral=speaker} +local speaker = peripheral.find("speaker") + +local t, dt = 0, 2 * math.pi * 220 / 48000 +while true do + local buffer = {} + for i = 1, 16 * 1024 * 8 do + buffer[i] = math.floor(math.sin(t) * 127) + t = (t + dt) % (math.pi * 2) + end + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` + +It looks pretty similar to before, aside from we've wrapped the generation and playing code in a while loop, and added a +rather odd loop with @{speaker.playAudio} and @{os.pullEvent}. + +Let's talk about this loop, why do we need to keep calling @{speaker.playAudio}? Remember that what we're trying to do +here is avoid keeping too much audio in memory at once. However, if we're generating audio quicker than the speakers can +play it, we're not helping at all - all this audio is still hanging around waiting to be played! + +In order to avoid this, the speaker rejects any new chunks of audio if its backlog is too large. When this happens, +@{speaker.playAudio} returns false. Once enough audio has played, and the backlog has been reduced, a +@{speaker_audio_empty} event is queued, and we can try to play our chunk once more. + +## Storing audio +PCM is a fantastic way of representing audio when we want to manipulate it, but it's not very efficient when we want to +store it to disk. Compare the size of a WAV file (which uses PCM) to an equivalent MP3, it's often 5 times the size. +Instead, we store audio in special formats (or *codecs*) and then convert them to PCM when we need to do processing on +them. + +Modern audio codecs use some incredibly impressive techniques to compress the audio as much as possible while preserving +sound quality. However, due to CC: Tweaked's limited processing power, it's not really possible to use these from your +computer. Instead, we need something much simpler. + +DFPWM (Dynamic Filter Pulse Width Modulation) is the de facto standard audio format of the ComputerCraft (and +OpenComputers) world. Originally popularised by the addon mod [Computronics], CC:T now has built-in support for it with +the @{cc.audio.dfpwm} module. This allows you to read DFPWM files from disk, decode them to PCM, and then play them +using the speaker. + +Let's dive in with an example, and we'll explain things afterwards: + +```lua {data-peripheral=speaker} +local dfpwm = require("cc.audio.dfpwm") +local speaker = peripheral.find("speaker") + +local decoder = dfpwm.make_decoder() +for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + local buffer = decoder(chunk) + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` + +Once again, we see the @{speaker.playAudio}/@{speaker_audio_empty} loop. However, the rest of the program is a little +different. + +First, we require the dfpwm module and call @{cc.audio.dfpwm.make_decoder} to construct a new decoder. This decoder +accepts blocks of DFPWM data and converts it to a list of 8-bit amplitudes, which we can then play with our speaker. + +As mentioned to above, @{speaker.playAudio} accepts at most 128×1024 samples in one go. DFPMW uses a single bit for each +sample, which means we want to process our audio in chunks of 16×1024 bytes (16KiB). In order to do this, we use +@{io.lines}, which provides a nice way to loop over chunks of a file. You can of course just use @{fs.open} and +@{fs.BinaryReadHandle.read} if you prefer. + +## Processing audio +As mentioned near the beginning of this guide, PCM audio is pretty easy to work with as it's just a list of amplitudes. +You can mix together samples from different streams by adding their amplitudes, change the rate of playback by removing +samples, etc... + +Let's put together a small demonstration here. We're going to add a small delay effect to the song above, so that you +hear a faint echo about a second later. + +In order to do this, we'll follow a format similar to the previous example, decoding the audio and then playing it. +However, we'll also add some new logic between those two steps, which loops over every sample in our chunk of audio, and +adds the sample from one second ago to it. + +For this, we'll need to keep track of the last 48k samples - exactly one seconds worth of audio. We can do this using a +[Ring Buffer], which helps makes things a little more efficient. + +```lua +local dfpwm = require("cc.audio.dfpwm") +local speaker = peripheral.find("speaker") + +-- Speakers play at 48kHz, so one second is 48k samples. We first fill our buffer +-- with 0s, as there's nothing to echo at the start of the track! +local samples_i, samples_n = 1, 48000 +local samples = {} +for i = 1, samples_n do samples[i] = 0 end + +local decoder = dfpwm.make_decoder() +for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + local buffer = decoder(input) + + for i = 1, #buffer do + local original_value = buffer[i] + + -- Replace this sample with its current amplitude plus the amplitude from one second ago. + -- We scale both to ensure the resulting value is still between -128 and 127. + buffer[i] = original_value * 0.7 + samples[samples_i] * 0.3 + + -- Now store the current sample, and move the "head" of our ring buffer forward one place. + samples[samples_i] = original_value + samples_i = samples_i + 1 + if samples_i > samples_n then samples_i = 1 end + end + + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end +end +``` + +:::note Confused? +Don't worry if you don't understand this example. It's quite advanced, and does use some ideas that this guide doesn't +cover. That said, don't be afraid to ask on [Discord] or [IRC] either! +::: + +It's worth noting that the examples of audio processing we've mentioned here are about manipulating the _amplitude_ of +the wave. If you wanted to modify the _frequency_ (for instance, shifting the pitch), things get rather more complex. +For this, you'd need to use the [Fast Fourier transform][FFT] to convert the stream of amplitudes to frequencies, +process those, and then convert them back to amplitudes. + +This is, I'm afraid, left as an exercise to the reader. + +[Computronics]: https://github.com/Vexatos/Computronics/ "Computronics on GitHub" +[FFT]: https://en.wikipedia.org/wiki/Fast_Fourier_transform "Fast Fourier transform - Wikipedia" +[PCM]: https://en.wikipedia.org/wiki/Pulse-code_modulation "Pulse-code Modulation - Wikipedia" +[Ring Buffer]: https://en.wikipedia.org/wiki/Circular_buffer "Circular buffer - Wikipedia" +[Sine Wave]: https://en.wikipedia.org/wiki/Sine_wave "Sine wave - Wikipedia" + +[Discord]: https://discord.computercraft.cc "The Minecraft Computer Mods Discord" +[IRC]: http://webchat.esper.net/?channels=computercraft "IRC webchat on EsperNet" diff --git a/gradle.properties b/gradle.properties index 2340513f5..7de002aa5 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,5 +4,5 @@ mod_version=1.99.1 # Minecraft properties (update mods.toml when changing) mc_version=1.16.5 mapping_version=2021.08.08 -forge_version=36.1.0 +forge_version=36.2.20 # NO SERIOUSLY, UPDATE mods.toml WHEN CHANGING diff --git a/illuaminate.sexp b/illuaminate.sexp index ac3ae44e4..91d1e99ef 100644 --- a/illuaminate.sexp +++ b/illuaminate.sexp @@ -1,8 +1,9 @@ ; -*- mode: Lisp;-*- (sources - /doc/stub/ /doc/events/ + /doc/guides/ + /doc/stub/ /build/docs/luaJavadoc/ /src/main/resources/*/computercraft/lua/bios.lua /src/main/resources/*/computercraft/lua/rom/ @@ -27,7 +28,8 @@ (module-kinds (peripheral Peripherals) (generic_peripheral "Generic Peripherals") - (event Events)) + (event Events) + (guide Guides)) (library-path /doc/stub/ diff --git a/src/main/java/dan200/computercraft/api/peripheral/GenericPeripheral.java b/src/main/java/dan200/computercraft/api/peripheral/GenericPeripheral.java index 02e13fd7a..9441dcad3 100644 --- a/src/main/java/dan200/computercraft/api/peripheral/GenericPeripheral.java +++ b/src/main/java/dan200/computercraft/api/peripheral/GenericPeripheral.java @@ -23,7 +23,7 @@ public interface GenericPeripheral extends GenericSource * Get the type of the exposed peripheral. * * Unlike normal {@link IPeripheral}s, {@link GenericPeripheral} do not have to have a type. By default, the - * resulting peripheral uses the resource name of the wrapped {@link TileEntity} (for instance {@literal minecraft:chest}). + * resulting peripheral uses the resource name of the wrapped {@link TileEntity} (for instance {@code minecraft:chest}). * * However, in some cases it may be more appropriate to specify a more readable name. Overriding this method allows * you to do so. diff --git a/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java b/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java index c80526eda..d273b43ca 100644 --- a/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java +++ b/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java @@ -63,7 +63,7 @@ public static PeripheralType ofType( @Nonnull String type ) * Create a new non-empty peripheral type with additional traits. * * @param type The name of the type. - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofType( @Nonnull String type, Collection additionalTypes ) @@ -76,7 +76,7 @@ public static PeripheralType ofType( @Nonnull String type, Collection ad * Create a new non-empty peripheral type with additional traits. * * @param type The name of the type. - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofType( @Nonnull String type, @Nonnull String... additionalTypes ) @@ -88,7 +88,7 @@ public static PeripheralType ofType( @Nonnull String type, @Nonnull String... ad /** * Create a new peripheral type with no primary type but additional traits. * - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofAdditional( Collection additionalTypes ) @@ -99,7 +99,7 @@ public static PeripheralType ofAdditional( Collection additionalTypes ) /** * Create a new peripheral type with no primary type but additional traits. * - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofAdditional( @Nonnull String... additionalTypes ) @@ -108,7 +108,7 @@ public static PeripheralType ofAdditional( @Nonnull String... additionalTypes ) } /** - * Get the name of this peripheral type. This may be {@literal null}. + * Get the name of this peripheral type. This may be {@code null}. * * @return The type of this peripheral. */ diff --git a/src/main/java/dan200/computercraft/client/ClientHooks.java b/src/main/java/dan200/computercraft/client/ClientHooks.java index 87b60cc44..392b6de58 100644 --- a/src/main/java/dan200/computercraft/client/ClientHooks.java +++ b/src/main/java/dan200/computercraft/client/ClientHooks.java @@ -6,6 +6,7 @@ package dan200.computercraft.client; import dan200.computercraft.ComputerCraft; +import dan200.computercraft.client.sound.SpeakerManager; import dan200.computercraft.shared.peripheral.monitor.ClientMonitor; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.ClientPlayerNetworkEvent; @@ -22,7 +23,7 @@ public static void onWorldUnload( WorldEvent.Unload event ) if( event.getWorld().isClientSide() ) { ClientMonitor.destroyAll(); - SoundManager.reset(); + SpeakerManager.reset(); } } diff --git a/src/main/java/dan200/computercraft/client/SoundManager.java b/src/main/java/dan200/computercraft/client/SoundManager.java deleted file mode 100644 index e4ea59c90..000000000 --- a/src/main/java/dan200/computercraft/client/SoundManager.java +++ /dev/null @@ -1,85 +0,0 @@ -/* - * This file is part of ComputerCraft - http://www.computercraft.info - * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. - * Send enquiries to dratcliffe@gmail.com - */ -package dan200.computercraft.client; - -import net.minecraft.client.Minecraft; -import net.minecraft.client.audio.ISound; -import net.minecraft.client.audio.ITickableSound; -import net.minecraft.client.audio.LocatableSound; -import net.minecraft.client.audio.SoundHandler; -import net.minecraft.util.ResourceLocation; -import net.minecraft.util.SoundCategory; -import net.minecraft.util.math.vector.Vector3d; - -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -public class SoundManager -{ - private static final Map sounds = new HashMap<>(); - - public static void playSound( UUID source, Vector3d position, ResourceLocation event, float volume, float pitch ) - { - SoundHandler soundManager = Minecraft.getInstance().getSoundManager(); - - MoveableSound oldSound = sounds.get( source ); - if( oldSound != null ) soundManager.stop( oldSound ); - - MoveableSound newSound = new MoveableSound( event, position, volume, pitch ); - sounds.put( source, newSound ); - soundManager.play( newSound ); - } - - public static void stopSound( UUID source ) - { - ISound sound = sounds.remove( source ); - if( sound == null ) return; - - Minecraft.getInstance().getSoundManager().stop( sound ); - } - - public static void moveSound( UUID source, Vector3d position ) - { - MoveableSound sound = sounds.get( source ); - if( sound != null ) sound.setPosition( position ); - } - - public static void reset() - { - sounds.clear(); - } - - private static class MoveableSound extends LocatableSound implements ITickableSound - { - protected MoveableSound( ResourceLocation sound, Vector3d position, float volume, float pitch ) - { - super( sound, SoundCategory.RECORDS ); - setPosition( position ); - this.volume = volume; - this.pitch = pitch; - attenuation = ISound.AttenuationType.LINEAR; - } - - void setPosition( Vector3d position ) - { - x = (float) position.x(); - y = (float) position.y(); - z = (float) position.z(); - } - - @Override - public boolean isStopped() - { - return false; - } - - @Override - public void tick() - { - } - } -} diff --git a/src/main/java/dan200/computercraft/client/sound/DfpwmStream.java b/src/main/java/dan200/computercraft/client/sound/DfpwmStream.java new file mode 100644 index 000000000..1448c5642 --- /dev/null +++ b/src/main/java/dan200/computercraft/client/sound/DfpwmStream.java @@ -0,0 +1,125 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.client.sound; + +import dan200.computercraft.shared.peripheral.speaker.SpeakerPeripheral; +import io.netty.buffer.ByteBuf; +import net.minecraft.client.audio.IAudioStream; +import org.lwjgl.BufferUtils; + +import javax.annotation.Nonnull; +import javax.sound.sampled.AudioFormat; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; +import java.util.ArrayDeque; +import java.util.Queue; + +class DfpwmStream implements IAudioStream +{ + public static final int SAMPLE_RATE = SpeakerPeripheral.SAMPLE_RATE; + + private static final int PREC = 10; + private static final int LPF_STRENGTH = 140; + + private static final AudioFormat MONO_16 = new AudioFormat( SAMPLE_RATE, 16, 1, true, false ); + + private final Queue buffers = new ArrayDeque<>( 2 ); + + private int charge = 0; // q + private int strength = 0; // s + private int lowPassCharge; + private boolean previousBit = false; + + DfpwmStream() + { + } + + void push( @Nonnull ByteBuf input ) + { + int readable = input.readableBytes(); + ByteBuffer output = ByteBuffer.allocate( readable * 16 ).order( ByteOrder.nativeOrder() ); + + for( int i = 0; i < readable; i++ ) + { + byte inputByte = input.readByte(); + for( int j = 0; j < 8; j++ ) + { + boolean currentBit = (inputByte & 1) != 0; + int target = currentBit ? 127 : -128; + + // q' <- q + (s * (t - q) + 128)/256 + int nextCharge = charge + ((strength * (target - charge) + (1 << (PREC - 1))) >> PREC); + if( nextCharge == charge && nextCharge != target ) nextCharge += currentBit ? 1 : -1; + + int z = currentBit == previousBit ? (1 << PREC) - 1 : 0; + + int nextStrength = strength; + if( strength != z ) nextStrength += currentBit == previousBit ? 1 : -1; + if( nextStrength < 2 << (PREC - 8) ) nextStrength = 2 << (PREC - 8); + + // Apply antijerk + int chargeWithAntijerk = currentBit == previousBit + ? nextCharge + : nextCharge + charge + 1 >> 1; + + // And low pass filter: outQ <- outQ + ((expectedOutput - outQ) x 140 / 256) + lowPassCharge += ((chargeWithAntijerk - lowPassCharge) * LPF_STRENGTH + 0x80) >> 8; + + charge = nextCharge; + strength = nextStrength; + previousBit = currentBit; + + // Ideally we'd generate an 8-bit audio buffer. However, as we're piggybacking on top of another + // audio stream (which uses 16 bit audio), we need to keep in the same format. + output.putShort( (short) ((byte) (lowPassCharge & 0xFF) << 8) ); + + inputByte >>= 1; + } + } + + output.flip(); + synchronized( this ) + { + buffers.add( output ); + } + } + + @Nonnull + @Override + public AudioFormat getFormat() + { + return MONO_16; + } + + @Nonnull + @Override + public synchronized ByteBuffer read( int capacity ) + { + ByteBuffer result = BufferUtils.createByteBuffer( capacity ); + while( result.hasRemaining() ) + { + ByteBuffer head = buffers.peek(); + if( head == null ) break; + + int toRead = Math.min( head.remaining(), result.remaining() ); + result.put( head.array(), head.position(), toRead ); // TODO: In 1.17 convert this to a ByteBuffer override + head.position( head.position() + toRead ); + + if( head.hasRemaining() ) break; + buffers.remove(); + } + + result.flip(); + return result; + } + + @Override + public void close() throws IOException + { + buffers.clear(); + } +} diff --git a/src/main/java/dan200/computercraft/client/sound/SpeakerInstance.java b/src/main/java/dan200/computercraft/client/sound/SpeakerInstance.java new file mode 100644 index 000000000..b328a65f0 --- /dev/null +++ b/src/main/java/dan200/computercraft/client/sound/SpeakerInstance.java @@ -0,0 +1,81 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.client.sound; + +import dan200.computercraft.ComputerCraft; +import io.netty.buffer.ByteBuf; +import net.minecraft.client.Minecraft; +import net.minecraft.client.audio.SoundHandler; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.math.vector.Vector3d; + +/** + * An instance of a speaker, which is either playing a {@link DfpwmStream} stream or a normal sound. + */ +public class SpeakerInstance +{ + public static final ResourceLocation DFPWM_STREAM = new ResourceLocation( ComputerCraft.MOD_ID, "speaker.dfpwm_fake_audio_should_not_be_played" ); + + private DfpwmStream currentStream; + private SpeakerSound sound; + + SpeakerInstance() + { + } + + public synchronized void pushAudio( ByteBuf buffer ) + { + if( currentStream == null ) currentStream = new DfpwmStream(); + currentStream.push( buffer ); + } + + public void playAudio( Vector3d position, float volume ) + { + SoundHandler soundManager = Minecraft.getInstance().getSoundManager(); + + if( sound != null && sound.stream != currentStream ) + { + soundManager.stop( sound ); + sound = null; + } + + if( sound != null && !soundManager.isActive( sound ) ) sound = null; + + if( sound == null && currentStream != null ) + { + sound = new SpeakerSound( DFPWM_STREAM, currentStream, position, volume, 1.0f ); + soundManager.play( sound ); + } + } + + public void playSound( Vector3d position, ResourceLocation location, float volume, float pitch ) + { + SoundHandler soundManager = Minecraft.getInstance().getSoundManager(); + currentStream = null; + + if( sound != null ) + { + soundManager.stop( sound ); + sound = null; + } + + sound = new SpeakerSound( location, null, position, volume, pitch ); + soundManager.play( sound ); + } + + void setPosition( Vector3d position ) + { + if( sound != null ) sound.setPosition( position ); + } + + void stop() + { + if( sound != null ) Minecraft.getInstance().getSoundManager().stop( sound ); + + currentStream = null; + sound = null; + } +} diff --git a/src/main/java/dan200/computercraft/client/sound/SpeakerManager.java b/src/main/java/dan200/computercraft/client/sound/SpeakerManager.java new file mode 100644 index 000000000..bae3cf801 --- /dev/null +++ b/src/main/java/dan200/computercraft/client/sound/SpeakerManager.java @@ -0,0 +1,58 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.client.sound; + +import net.minecraft.util.math.vector.Vector3d; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.client.event.sound.PlayStreamingSourceEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.fml.common.Mod; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Maps speakers source IDs to a {@link SpeakerInstance}. + */ +@Mod.EventBusSubscriber( Dist.CLIENT ) +public class SpeakerManager +{ + private static final Map sounds = new ConcurrentHashMap<>(); + + @SubscribeEvent + public static void playStreaming( PlayStreamingSourceEvent event ) + { + if( !(event.getSound() instanceof SpeakerSound) ) return; + SpeakerSound sound = (SpeakerSound) event.getSound(); + if( sound.stream == null ) return; + + event.getSource().attachBufferStream( sound.stream ); + event.getSource().play(); + } + + public static SpeakerInstance getSound( UUID source ) + { + return sounds.computeIfAbsent( source, x -> new SpeakerInstance() ); + } + + public static void stopSound( UUID source ) + { + SpeakerInstance sound = sounds.remove( source ); + if( sound != null ) sound.stop(); + } + + public static void moveSound( UUID source, Vector3d position ) + { + SpeakerInstance sound = sounds.get( source ); + if( sound != null ) sound.setPosition( position ); + } + + public static void reset() + { + sounds.clear(); + } +} diff --git a/src/main/java/dan200/computercraft/client/sound/SpeakerSound.java b/src/main/java/dan200/computercraft/client/sound/SpeakerSound.java new file mode 100644 index 000000000..7f6d78a67 --- /dev/null +++ b/src/main/java/dan200/computercraft/client/sound/SpeakerSound.java @@ -0,0 +1,54 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.client.sound; + +import net.minecraft.client.audio.IAudioStream; +import net.minecraft.client.audio.ITickableSound; +import net.minecraft.client.audio.LocatableSound; +import net.minecraft.util.ResourceLocation; +import net.minecraft.util.SoundCategory; +import net.minecraft.util.math.vector.Vector3d; + +import javax.annotation.Nullable; + +public class SpeakerSound extends LocatableSound implements ITickableSound +{ + DfpwmStream stream; + + SpeakerSound( ResourceLocation sound, DfpwmStream stream, Vector3d position, float volume, float pitch ) + { + super( sound, SoundCategory.RECORDS ); + setPosition( position ); + this.stream = stream; + this.volume = volume; + this.pitch = pitch; + attenuation = AttenuationType.LINEAR; + } + + void setPosition( Vector3d position ) + { + x = (float) position.x(); + y = (float) position.y(); + z = (float) position.z(); + } + + @Override + public boolean isStopped() + { + return false; + } + + @Override + public void tick() + { + } + + @Nullable + public IAudioStream getStream() + { + return stream; + } +} diff --git a/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java b/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java index e29a87837..b16844832 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java +++ b/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java @@ -15,7 +15,7 @@ import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; /** - * An alternative to {@link WebSocketClientCompressionHandler} which supports the {@literal client_no_context_takeover} + * An alternative to {@link WebSocketClientCompressionHandler} which supports the {@code client_no_context_takeover} * extension. Makes CC slightly more flexible. */ @ChannelHandler.Sharable diff --git a/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java b/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java index eead3b5a2..d327a9e56 100644 --- a/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java +++ b/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java @@ -57,10 +57,11 @@ public static void setup() registerMainThread( 13, NetworkDirection.PLAY_TO_CLIENT, ComputerTerminalClientMessage.class, ComputerTerminalClientMessage::new ); registerMainThread( 14, NetworkDirection.PLAY_TO_CLIENT, PlayRecordClientMessage.class, PlayRecordClientMessage::new ); registerMainThread( 15, NetworkDirection.PLAY_TO_CLIENT, MonitorClientMessage.class, MonitorClientMessage::new ); - registerMainThread( 16, NetworkDirection.PLAY_TO_CLIENT, SpeakerPlayClientMessage.class, SpeakerPlayClientMessage::new ); - registerMainThread( 17, NetworkDirection.PLAY_TO_CLIENT, SpeakerStopClientMessage.class, SpeakerStopClientMessage::new ); - registerMainThread( 18, NetworkDirection.PLAY_TO_CLIENT, SpeakerMoveClientMessage.class, SpeakerMoveClientMessage::new ); - registerMainThread( 19, NetworkDirection.PLAY_TO_CLIENT, UploadResultMessage.class, UploadResultMessage::new ); + registerMainThread( 16, NetworkDirection.PLAY_TO_CLIENT, SpeakerAudioClientMessage.class, SpeakerAudioClientMessage::new ); + registerMainThread( 17, NetworkDirection.PLAY_TO_CLIENT, SpeakerMoveClientMessage.class, SpeakerMoveClientMessage::new ); + registerMainThread( 18, NetworkDirection.PLAY_TO_CLIENT, SpeakerPlayClientMessage.class, SpeakerPlayClientMessage::new ); + registerMainThread( 19, NetworkDirection.PLAY_TO_CLIENT, SpeakerStopClientMessage.class, SpeakerStopClientMessage::new ); + registerMainThread( 20, NetworkDirection.PLAY_TO_CLIENT, UploadResultMessage.class, UploadResultMessage::new ); } public static void sendToPlayer( PlayerEntity player, NetworkMessage packet ) diff --git a/src/main/java/dan200/computercraft/shared/network/client/SpeakerAudioClientMessage.java b/src/main/java/dan200/computercraft/shared/network/client/SpeakerAudioClientMessage.java new file mode 100644 index 000000000..e16ff31af --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/network/client/SpeakerAudioClientMessage.java @@ -0,0 +1,69 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.shared.network.client; + +import dan200.computercraft.client.sound.SpeakerManager; +import dan200.computercraft.shared.network.NetworkMessage; +import net.minecraft.network.PacketBuffer; +import net.minecraft.util.math.vector.Vector3d; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.fml.network.NetworkEvent; + +import javax.annotation.Nonnull; +import java.nio.ByteBuffer; +import java.util.UUID; + +/** + * Starts a sound on the client. + * + * Used by speakers to play sounds. + * + * @see dan200.computercraft.shared.peripheral.speaker.TileSpeaker + */ +public class SpeakerAudioClientMessage implements NetworkMessage +{ + private final UUID source; + private final Vector3d pos; + private final ByteBuffer content; + private final float volume; + + public SpeakerAudioClientMessage( UUID source, Vector3d pos, float volume, ByteBuffer content ) + { + this.source = source; + this.pos = pos; + this.content = content; + this.volume = volume; + } + + public SpeakerAudioClientMessage( PacketBuffer buf ) + { + source = buf.readUUID(); + pos = new Vector3d( buf.readDouble(), buf.readDouble(), buf.readDouble() ); + volume = buf.readFloat(); + + SpeakerManager.getSound( source ).pushAudio( buf ); + content = null; + } + + @Override + public void toBytes( @Nonnull PacketBuffer buf ) + { + buf.writeUUID( source ); + buf.writeDouble( pos.x() ); + buf.writeDouble( pos.y() ); + buf.writeDouble( pos.z() ); + buf.writeFloat( volume ); + buf.writeBytes( content.duplicate() ); + } + + @Override + @OnlyIn( Dist.CLIENT ) + public void handle( NetworkEvent.Context context ) + { + SpeakerManager.getSound( source ).playAudio( pos, volume ); + } +} diff --git a/src/main/java/dan200/computercraft/shared/network/client/SpeakerMoveClientMessage.java b/src/main/java/dan200/computercraft/shared/network/client/SpeakerMoveClientMessage.java index d7fc9535d..730a5e39d 100644 --- a/src/main/java/dan200/computercraft/shared/network/client/SpeakerMoveClientMessage.java +++ b/src/main/java/dan200/computercraft/shared/network/client/SpeakerMoveClientMessage.java @@ -5,7 +5,7 @@ */ package dan200.computercraft.shared.network.client; -import dan200.computercraft.client.SoundManager; +import dan200.computercraft.client.sound.SpeakerManager; import dan200.computercraft.shared.network.NetworkMessage; import net.minecraft.network.PacketBuffer; import net.minecraft.util.math.vector.Vector3d; @@ -53,6 +53,6 @@ public void toBytes( @Nonnull PacketBuffer buf ) @OnlyIn( Dist.CLIENT ) public void handle( NetworkEvent.Context context ) { - SoundManager.moveSound( source, pos ); + SpeakerManager.moveSound( source, pos ); } } diff --git a/src/main/java/dan200/computercraft/shared/network/client/SpeakerPlayClientMessage.java b/src/main/java/dan200/computercraft/shared/network/client/SpeakerPlayClientMessage.java index 4d7bbff34..2184efc77 100644 --- a/src/main/java/dan200/computercraft/shared/network/client/SpeakerPlayClientMessage.java +++ b/src/main/java/dan200/computercraft/shared/network/client/SpeakerPlayClientMessage.java @@ -5,7 +5,7 @@ */ package dan200.computercraft.shared.network.client; -import dan200.computercraft.client.SoundManager; +import dan200.computercraft.client.sound.SpeakerManager; import dan200.computercraft.shared.network.NetworkMessage; import net.minecraft.network.PacketBuffer; import net.minecraft.util.ResourceLocation; @@ -66,6 +66,6 @@ public void toBytes( @Nonnull PacketBuffer buf ) @OnlyIn( Dist.CLIENT ) public void handle( NetworkEvent.Context context ) { - SoundManager.playSound( source, pos, sound, volume, pitch ); + SpeakerManager.getSound( source ).playSound( pos, sound, volume, pitch ); } } diff --git a/src/main/java/dan200/computercraft/shared/network/client/SpeakerStopClientMessage.java b/src/main/java/dan200/computercraft/shared/network/client/SpeakerStopClientMessage.java index e1a10455f..5e36186a4 100644 --- a/src/main/java/dan200/computercraft/shared/network/client/SpeakerStopClientMessage.java +++ b/src/main/java/dan200/computercraft/shared/network/client/SpeakerStopClientMessage.java @@ -5,7 +5,7 @@ */ package dan200.computercraft.shared.network.client; -import dan200.computercraft.client.SoundManager; +import dan200.computercraft.client.sound.SpeakerManager; import dan200.computercraft.shared.network.NetworkMessage; import net.minecraft.network.PacketBuffer; import net.minecraftforge.api.distmarker.Dist; @@ -46,6 +46,6 @@ public void toBytes( @Nonnull PacketBuffer buf ) @OnlyIn( Dist.CLIENT ) public void handle( NetworkEvent.Context context ) { - SoundManager.stopSound( source ); + SpeakerManager.stopSound( source ); } } diff --git a/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java b/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java index 051938fbd..273e78771 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java @@ -29,7 +29,7 @@ /** * This peripheral allows you to interact with command blocks. * - * Command blocks are only wrapped as peripherals if the {@literal enable_command_block} option is true within the + * Command blocks are only wrapped as peripherals if the {@code enable_command_block} option is true within the * config. * * This API is not the same as the {@link CommandAPI} API, which is exposed on command computers. diff --git a/src/main/java/dan200/computercraft/shared/peripheral/speaker/DfpwmState.java b/src/main/java/dan200/computercraft/shared/peripheral/speaker/DfpwmState.java new file mode 100644 index 000000000..47638a970 --- /dev/null +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/DfpwmState.java @@ -0,0 +1,117 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.shared.peripheral.speaker; + +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.lua.LuaTable; +import net.minecraft.util.math.MathHelper; + +import javax.annotation.Nonnull; +import java.nio.ByteBuffer; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import static dan200.computercraft.shared.peripheral.speaker.SpeakerPeripheral.SAMPLE_RATE; + +/** + * Internal state of the DFPWM decoder and the state of playback. + */ +class DfpwmState +{ + private static final long SECOND = TimeUnit.SECONDS.toNanos( 1 ); + + /** + * The minimum size of the client's audio buffer. Once we have less than this on the client, we should send another + * batch of audio. + */ + private static final long CLIENT_BUFFER = (long) (SECOND * 1.5); + + private static final int PREC = 10; + + private int charge = 0; // q + private int strength = 0; // s + private boolean previousBit = false; + + private boolean unplayed = true; + private long clientEndTime = System.nanoTime(); + private float pendingVolume = 1.0f; + private ByteBuffer pendingAudio; + + synchronized boolean pushBuffer( LuaTable table, int size, @Nonnull Optional volume ) throws LuaException + { + if( pendingAudio != null ) return false; + + int outSize = size / 8; + ByteBuffer buffer = ByteBuffer.allocate( outSize ); + + for( int i = 0; i < outSize; i++ ) + { + int thisByte = 0; + for( int j = 1; j <= 8; j++ ) + { + int level = table.getInt( i * 8 + j ); + if( level < -128 || level > 127 ) + { + throw new LuaException( "table item #" + (i * 8 + j) + " must be between -128 and 127" ); + } + + boolean currentBit = level > charge || (level == charge && charge == 127); + + // Identical to DfpwmStream. Not happy with this, but saves some inheritance. + int target = currentBit ? 127 : -128; + + // q' <- q + (s * (t - q) + 128)/256 + int nextCharge = charge + ((strength * (target - charge) + (1 << (PREC - 1))) >> PREC); + if( nextCharge == charge && nextCharge != target ) nextCharge += currentBit ? 1 : -1; + + int z = currentBit == previousBit ? (1 << PREC) - 1 : 0; + + int nextStrength = strength; + if( strength != z ) nextStrength += currentBit == previousBit ? 1 : -1; + if( nextStrength < 2 << (PREC - 8) ) nextStrength = 2 << (PREC - 8); + + charge = nextCharge; + strength = nextStrength; + previousBit = currentBit; + + thisByte = (thisByte >> 1) + (currentBit ? 128 : 0); + } + + buffer.put( (byte) thisByte ); + } + + buffer.flip(); + + pendingAudio = buffer; + pendingVolume = MathHelper.clamp( volume.orElse( (double) pendingVolume ).floatValue(), 0.0f, 3.0f ); + return true; + } + + boolean shouldSendPending( long now ) + { + return pendingAudio != null && now >= clientEndTime - CLIENT_BUFFER; + } + + ByteBuffer pullPending( long now ) + { + ByteBuffer audio = pendingAudio; + pendingAudio = null; + // Compute when we should consider sending the next packet. + clientEndTime = Math.max( now, clientEndTime ) + (audio.remaining() * SECOND * 8 / SAMPLE_RATE); + unplayed = false; + return audio; + } + + boolean isPlaying() + { + return unplayed || clientEndTime >= System.nanoTime(); + } + + float getVolume() + { + return pendingVolume; + } +} diff --git a/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java b/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java index 3a459e4ab..d361b4d75 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java @@ -9,10 +9,14 @@ import dan200.computercraft.api.lua.ILuaContext; import dan200.computercraft.api.lua.LuaException; import dan200.computercraft.api.lua.LuaFunction; +import dan200.computercraft.api.lua.LuaTable; +import dan200.computercraft.api.peripheral.IComputerAccess; import dan200.computercraft.api.peripheral.IPeripheral; import dan200.computercraft.shared.network.NetworkHandler; +import dan200.computercraft.shared.network.client.SpeakerAudioClientMessage; import dan200.computercraft.shared.network.client.SpeakerMoveClientMessage; import dan200.computercraft.shared.network.client.SpeakerPlayClientMessage; +import dan200.computercraft.shared.network.client.SpeakerStopClientMessage; import net.minecraft.network.play.server.SPlaySoundPacket; import net.minecraft.server.MinecraftServer; import net.minecraft.state.properties.NoteBlockInstrument; @@ -20,66 +24,161 @@ import net.minecraft.util.ResourceLocationException; import net.minecraft.util.SoundCategory; import net.minecraft.util.math.BlockPos; -import net.minecraft.util.math.MathHelper; import net.minecraft.util.math.vector.Vector3d; import net.minecraft.world.World; import javax.annotation.Nonnull; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.atomic.AtomicInteger; +import javax.annotation.Nullable; +import java.util.*; import static dan200.computercraft.api.lua.LuaValues.checkFinite; /** - * Speakers allow playing notes and other sounds. + * The speaker peirpheral allow your computer to play notes and other sounds. + * + * The speaker can play three kinds of sound, in increasing orders of complexity: + * - {@link #playNote} allows you to play noteblock note. + * - {@link #playSound} plays any built-in Minecraft sound, such as block sounds or mob noises. + * - {@link #playAudio} can play arbitrary audio. * * @cc.module speaker * @cc.since 1.80pr1 */ public abstract class SpeakerPeripheral implements IPeripheral { - private static final int MIN_TICKS_BETWEEN_SOUNDS = 1; + /** + * Number of samples/s in a dfpwm1a audio track. + */ + public static final int SAMPLE_RATE = 48000; + + private final UUID source = UUID.randomUUID(); + private final Set computers = Collections.newSetFromMap( new HashMap<>() ); private long clock = 0; - private long lastPlayTime = 0; - private final AtomicInteger notesThisTick = new AtomicInteger(); - private long lastPositionTime; private Vector3d lastPosition; + private long lastPlayTime; + + private final List pendingNotes = new ArrayList<>(); + + private final Object lock = new Object(); + private boolean shouldStop; + private PendingSound pendingSound = null; + private DfpwmState dfpwmState; + public void update() { clock++; - notesThisTick.set( 0 ); + + Vector3d pos = getPosition(); + World world = getWorld(); + if( world == null ) return; + MinecraftServer server = world.getServer(); + + synchronized( pendingNotes ) + { + for( PendingSound sound : pendingNotes ) + { + lastPlayTime = clock; + server.getPlayerList().broadcast( + null, pos.x, pos.y, pos.z, sound.volume * 16, world.dimension(), + new SPlaySoundPacket( sound.location, SoundCategory.RECORDS, pos, sound.volume, sound.pitch ) + ); + } + pendingNotes.clear(); + } + + // The audio dispatch logic here is pretty messy, which I'm not proud of. The general logic here is that we hold + // the main "lock" when modifying the dfpwmState/pendingSound variables and no other time. + // dfpwmState will only ever transition from having a buffer to not having a buffer on the main thread (so this + // method), so we don't need to bother locking that. + boolean shouldStop; + PendingSound sound; + DfpwmState dfpwmState; + synchronized( lock ) + { + sound = pendingSound; + dfpwmState = this.dfpwmState; + pendingSound = null; + + shouldStop = this.shouldStop; + if( shouldStop ) + { + dfpwmState = this.dfpwmState = null; + sound = null; + this.shouldStop = false; + } + } + + // Stop the speaker and nuke the position, so we don't update it again. + if( shouldStop && lastPosition != null ) + { + lastPosition = null; + NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( getSource() ) ); + return; + } + + long now = System.nanoTime(); + if( sound != null ) + { + lastPlayTime = clock; + NetworkHandler.sendToAllAround( + new SpeakerPlayClientMessage( getSource(), pos, sound.location, sound.volume, sound.pitch ), + world, pos, sound.volume * 16 + ); + syncedPosition( pos ); + } + else if( dfpwmState != null && dfpwmState.shouldSendPending( now ) ) + { + // If clients need to receive another batch of audio, send it and then notify computers our internal buffer is + // free again. + NetworkHandler.sendToAllTracking( + new SpeakerAudioClientMessage( getSource(), pos, dfpwmState.getVolume(), dfpwmState.pullPending( now ) ), + getWorld().getChunkAt( new BlockPos( pos ) ) + ); + syncedPosition( pos ); + + // And notify computers that we have space for more audio. + for( IComputerAccess computer : computers ) + { + computer.queueEvent( "speaker_audio_empty", computer.getAttachmentName() ); + } + } // Push position updates to any speakers which have ever played a note, // have moved by a non-trivial amount and haven't had a position update // in the last second. - if( lastPlayTime > 0 && (clock - lastPositionTime) >= 20 ) + if( lastPosition != null && (clock - lastPositionTime) >= 20 ) { Vector3d position = getPosition(); - if( lastPosition == null || lastPosition.distanceToSqr( position ) >= 0.1 ) + if( lastPosition.distanceToSqr( position ) >= 0.1 ) { - lastPosition = position; - lastPositionTime = clock; NetworkHandler.sendToAllTracking( new SpeakerMoveClientMessage( getSource(), position ), getWorld().getChunkAt( new BlockPos( position ) ) ); + syncedPosition( position ); } } } + @Nullable public abstract World getWorld(); + @Nonnull public abstract Vector3d getPosition(); - protected abstract UUID getSource(); - - public boolean madeSound( long ticks ) + @Nonnull + public UUID getSource() { - return clock - lastPlayTime <= ticks; + return source; + } + + public boolean madeSound() + { + DfpwmState state = dfpwmState; + return clock - lastPlayTime <= 20 || (state != null && state.isPlaying()); } @Nonnull @@ -90,18 +189,81 @@ public String getType() } /** - * Plays a sound through the speaker. + * Plays a note block note through the speaker. * - * This plays sounds similar to the {@code /playsound} command in Minecraft. - * It takes the namespaced path of a sound (e.g. {@code minecraft:block.note_block.harp}) - * with an optional volume and speed multiplier, and plays it through the speaker. + * This takes the name of a note to play, as well as optionally the volume + * and pitch to play the note at. + * + * The pitch argument uses semitones as the unit. This directly maps to the + * number of clicks on a note block. For reference, 0, 12, and 24 map to F#, + * and 6 and 18 map to C. + * + * A maximum of 8 notes can be played in a single tick. If this limit is hit, this function will return + * {@literal false}. + * + * ### Valid instruments + * The speaker supports [all of Minecraft's noteblock instruments](https://minecraft.fandom.com/wiki/Note_Block#Instruments). + * These are: + * + * {@code "harp"}, {@code "basedrum"}, {@code "snare"}, {@code "hat"}, {@code "bass"}, @code "flute"}, + * {@code "bell"}, {@code "guitar"}, {@code "chime"}, {@code "xylophone"}, {@code "iron_xylophone"}, + * {@code "cow_bell"}, {@code "didgeridoo"}, {@code "bit"}, {@code "banjo"} and {@code "pling"}. + * + * @param context The Lua context + * @param instrumentA The instrument to use to play this note. + * @param volumeA The volume to play the note at, from 0.0 to 3.0. Defaults to 1.0. + * @param pitchA The pitch to play the note at in semitones, from 0 to 24. Defaults to 12. + * @return Whether the note could be played as the limit was reached. + * @throws LuaException If the instrument doesn't exist. + */ + @LuaFunction + public final boolean playNote( ILuaContext context, String instrumentA, Optional volumeA, Optional pitchA ) throws LuaException + { + float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) ); + float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) ); + + NoteBlockInstrument instrument = null; + for( NoteBlockInstrument testInstrument : NoteBlockInstrument.values() ) + { + if( testInstrument.getSerializedName().equalsIgnoreCase( instrumentA ) ) + { + instrument = testInstrument; + break; + } + } + + // Check if the note exists + if( instrument == null ) throw new LuaException( "Invalid instrument, \"" + instrument + "\"!" ); + + synchronized( pendingNotes ) + { + if( pendingNotes.size() >= ComputerCraft.maxNotesPerTick ) return false; + pendingNotes.add( new PendingSound( instrument.getSoundEvent().getRegistryName(), volume, (float) Math.pow( 2.0, (pitch - 12.0) / 12.0 ) ) ); + } + return true; + } + + /** + * Plays a Minecraft sound through the speaker. + * + * This takes the [name of a Minecraft sound](https://minecraft.fandom.com/wiki/Sounds.json), such as + * {@code "minecraft:block.note_block.harp"}, as well as an optional volume and pitch. + * + * Only one sound can be played at once. This function will return {@literal false} if another sound was started + * this tick, or if some {@link #playAudio audio} is still playing. * * @param context The Lua context * @param name The name of the sound to play. * @param volumeA The volume to play the sound at, from 0.0 to 3.0. Defaults to 1.0. * @param pitchA The speed to play the sound at, from 0.5 to 2.0. Defaults to 1.0. * @return Whether the sound could be played. - * @throws LuaException If the sound name couldn't be decoded. + * @throws LuaException If the sound name was invalid. + * @cc.usage Play a creeper hiss with the speaker. + * + *
{@code
+     * local speaker = peripheral.find("speaker")
+     * speaker.playSound("entity.creeper.primed")
+     * }
*/ @LuaFunction public final boolean playSound( ILuaContext context, String name, Optional volumeA, Optional pitchA ) throws LuaException @@ -119,89 +281,123 @@ public final boolean playSound( ILuaContext context, String name, Optional{@code + * local dfpwm = require("cc.audio.dfpwm") + * local speaker = peripheral.find("speaker") + * + * local decoder = dfpwm.make_decoder() + * for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + * local buffer = decoder(chunk) + * + * while not speaker.playAudio(buffer) do + * os.pullEvent("speaker_audio_empty") + * end + * end + * } + * @cc.see cc.audio.dfpwm Provides utilities for decoding DFPWM audio files into a format which can be played by + * the speaker. + * @cc.see speaker_audio For a more complete introduction to the {@link #playAudio} function. + */ + @LuaFunction( unsafe = true ) + public final boolean playAudio( ILuaContext context, LuaTable audio, Optional volume ) throws LuaException + { + checkFinite( 1, volume.orElse( 0.0 ) ); + + // TODO: Use ArgumentHelpers instead? + int length = audio.length(); + if( length <= 0 ) throw new LuaException( "Cannot play empty audio" ); + if( length > 1024 * 16 * 8 ) throw new LuaException( "Audio data is too large" ); + + DfpwmState state; + synchronized( lock ) + { + if( dfpwmState == null || !dfpwmState.isPlaying() ) dfpwmState = new DfpwmState(); + state = dfpwmState; + + pendingSound = null; + } + + return state.pushBuffer( audio, length, volume ); + } + + /** + * Stop all audio being played by this speaker. + * + * This clears any audio that {@link #playAudio} had queued and stops the latest sound played by {@link #playSound}. + * + * @cc.since 1.100 */ @LuaFunction - public final synchronized boolean playNote( ILuaContext context, String name, Optional volumeA, Optional pitchA ) throws LuaException + public final void stop() { - float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) ); - float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) ); - - NoteBlockInstrument instrument = null; - for( NoteBlockInstrument testInstrument : NoteBlockInstrument.values() ) - { - if( testInstrument.getSerializedName().equalsIgnoreCase( name ) ) - { - instrument = testInstrument; - break; - } - } - - // Check if the note exists - if( instrument == null ) throw new LuaException( "Invalid instrument, \"" + name + "\"!" ); - - // If the resource location for note block notes changes, this method call will need to be updated - boolean success = playSound( context, instrument.getSoundEvent().getRegistryName(), volume, (float) Math.pow( 2.0, (pitch - 12.0) / 12.0 ), true ); - if( success ) notesThisTick.incrementAndGet(); - return success; + shouldStop = true; } - private synchronized boolean playSound( ILuaContext context, ResourceLocation name, float volume, float pitch, boolean isNote ) throws LuaException + private void syncedPosition( Vector3d position ) { - if( clock - lastPlayTime < MIN_TICKS_BETWEEN_SOUNDS ) + lastPosition = position; + lastPositionTime = clock; + } + + @Override + public void attach( @Nonnull IComputerAccess computer ) + { + computers.add( computer ); + } + + @Override + public void detach( @Nonnull IComputerAccess computer ) + { + computers.remove( computer ); + } + + private static final class PendingSound + { + final ResourceLocation location; + final float volume; + final float pitch; + + private PendingSound( ResourceLocation location, float volume, float pitch ) { - // Rate limiting occurs when we've already played a sound within the last tick. - if( !isNote ) return false; - // Or we've played more notes than allowable within the current tick. - if( clock - lastPlayTime != 0 || notesThisTick.get() >= ComputerCraft.maxNotesPerTick ) return false; + this.location = location; + this.volume = volume; + this.pitch = pitch; } - - World world = getWorld(); - Vector3d pos = getPosition(); - - float actualVolume = MathHelper.clamp( volume, 0.0f, 3.0f ); - float range = actualVolume * 16; - - context.issueMainThreadTask( () -> { - MinecraftServer server = world.getServer(); - if( server == null ) return null; - - if( isNote ) - { - server.getPlayerList().broadcast( - null, pos.x, pos.y, pos.z, range, world.dimension(), - new SPlaySoundPacket( name, SoundCategory.RECORDS, pos, actualVolume, pitch ) - ); - } - else - { - NetworkHandler.sendToAllAround( - new SpeakerPlayClientMessage( getSource(), pos, name, actualVolume, pitch ), - world, pos, range - ); - } - return null; - } ); - - lastPlayTime = clock; - return true; } } diff --git a/src/main/java/dan200/computercraft/shared/peripheral/speaker/TileSpeaker.java b/src/main/java/dan200/computercraft/shared/peripheral/speaker/TileSpeaker.java index 264619ae6..8f77bec54 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/speaker/TileSpeaker.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/TileSpeaker.java @@ -21,7 +21,6 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.UUID; import static dan200.computercraft.shared.Capabilities.CAPABILITY_PERIPHERAL; @@ -29,7 +28,6 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity { private final SpeakerPeripheral peripheral; private LazyOptional peripheralCap; - private final UUID source = UUID.randomUUID(); public TileSpeaker( TileEntityType type ) { @@ -49,7 +47,7 @@ public void setRemoved() super.setRemoved(); if( level != null && !level.isClientSide ) { - NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( source ) ); + NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( peripheral.getSource() ) ); } } @@ -88,6 +86,7 @@ public World getWorld() return speaker.getLevel(); } + @Nonnull @Override public Vector3d getPosition() { @@ -95,12 +94,6 @@ public Vector3d getPosition() return new Vector3d( pos.getX(), pos.getY(), pos.getZ() ); } - @Override - protected UUID getSource() - { - return speaker.source; - } - @Override public boolean equals( @Nullable IPeripheral other ) { diff --git a/src/main/java/dan200/computercraft/shared/peripheral/speaker/UpgradeSpeakerPeripheral.java b/src/main/java/dan200/computercraft/shared/peripheral/speaker/UpgradeSpeakerPeripheral.java index 52a9dc805..90e6e5616 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/speaker/UpgradeSpeakerPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/UpgradeSpeakerPeripheral.java @@ -9,32 +9,22 @@ import dan200.computercraft.shared.network.NetworkHandler; import dan200.computercraft.shared.network.client.SpeakerStopClientMessage; import net.minecraft.server.MinecraftServer; -import net.minecraftforge.fml.LogicalSide; -import net.minecraftforge.fml.LogicalSidedProvider; +import net.minecraftforge.fml.server.ServerLifecycleHooks; import javax.annotation.Nonnull; -import java.util.UUID; /** * A speaker peripheral which is used on an upgrade, and so is only attached to one computer. */ public abstract class UpgradeSpeakerPeripheral extends SpeakerPeripheral { - private final UUID source = UUID.randomUUID(); - - @Override - protected final UUID getSource() - { - return source; - } - @Override public void detach( @Nonnull IComputerAccess computer ) { // We could be in the process of shutting down the server, so we can't send packets in this case. - MinecraftServer server = LogicalSidedProvider.INSTANCE.get( LogicalSide.SERVER ); + MinecraftServer server = ServerLifecycleHooks.getCurrentServer(); if( server == null || server.isStopped() ) return; - NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( source ) ); + NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( getSource() ) ); } } diff --git a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeaker.java b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeaker.java index e0cb29e99..bf1d72bd2 100644 --- a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeaker.java +++ b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeaker.java @@ -43,6 +43,6 @@ public void update( @Nonnull IPocketAccess access, @Nullable IPeripheral periphe } speaker.update(); - access.setLight( speaker.madeSound( 20 ) ? 0x3320fc : -1 ); + access.setLight( speaker.madeSound() ? 0x3320fc : -1 ); } } diff --git a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java index 8d272a9d8..f693ea80b 100644 --- a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java @@ -10,6 +10,8 @@ import net.minecraft.util.math.vector.Vector3d; import net.minecraft.world.World; +import javax.annotation.Nonnull; + public class PocketSpeakerPeripheral extends UpgradeSpeakerPeripheral { private World world = null; @@ -27,6 +29,7 @@ public World getWorld() return world; } + @Nonnull @Override public Vector3d getPosition() { diff --git a/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleSpeaker.java b/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleSpeaker.java index 04ece928c..7222d9234 100644 --- a/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleSpeaker.java +++ b/src/main/java/dan200/computercraft/shared/turtle/upgrades/TurtleSpeaker.java @@ -43,6 +43,7 @@ public World getWorld() return turtle.getWorld(); } + @Nonnull @Override public Vector3d getPosition() { diff --git a/src/main/resources/META-INF/mods.toml b/src/main/resources/META-INF/mods.toml index 165bf316e..55032ba56 100644 --- a/src/main/resources/META-INF/mods.toml +++ b/src/main/resources/META-INF/mods.toml @@ -20,6 +20,6 @@ CC: Tweaked is a fork of ComputerCraft, adding programmable computers, turtles a [[dependencies.computercraft]] modId="forge" mandatory=true - versionRange="[36.1.0,37)" + versionRange="[36.2.20,37)" ordering="NONE" side="BOTH" diff --git a/src/main/resources/assets/computercraft/sounds.json b/src/main/resources/assets/computercraft/sounds.json new file mode 100644 index 000000000..8f6c86ad2 --- /dev/null +++ b/src/main/resources/assets/computercraft/sounds.json @@ -0,0 +1,10 @@ +{ + "speaker.dfpwm_fake_audio_should_not_be_played": { + "sounds": [ + { + "name": "computercraft:empty", + "stream": true + } + ] + } +} diff --git a/src/main/resources/assets/computercraft/sounds/empty.ogg b/src/main/resources/assets/computercraft/sounds/empty.ogg new file mode 100644 index 0000000000000000000000000000000000000000..d3f6aa988dc6cdc46ede53db6f25872d280d9bbf GIT binary patch literal 3353 zcmahMYfzI{_JTYDQVb9<(Ab4Gkp$5pm?)o*E(t{7gB0?Gk4Lf@3YdzGu}X;5*YbbWoqL}5 zo_o%{C%Kz8$&oL5(S{ebJ*L19703L2-tgJ+L1o!y9fk0z`I`D5RFmT4{hsm3r6gZ9 zN|K`7&wKbVRP?{!wcQKm)w~AOoYEcd2~$?AUbTXslti(|!cV=m{DY0^%Jo1j2O@u^ zfTG53*<3a+=;J^CeN&uP;Db^TV(XX*8=Sf*BRxi(7k!5%9@adJ7UzW3vXXO@gB)E` zTw9B{Udi2kt%*dGz#h>~hS4md#Thy4i!6rt^#IjlfZI&r=;D`y1gik5h_=LdCn+Vw za5v>S%P7H$=zyDzH7*jLu^T!uU}g(5^9%vlEX#N?k!!B)OF9!}^b>L$YJW<){JmkJ zxZZBa5wde>ez41HMdiTV~@=nHF}jh2!nhA01B| zJn8MpUhzzX@?;^B?TkRL+(v|GEh%Pak=a>co~du78S6TIQ4K(Ng9t}AWoXsap;DxOgs3)03C4@huDE{#?jYO96S%jyLMf(Cs4TU!}s2Ggbe_+ zv};$BeP1^~{#~0J?b$AXGJAIspJY=w9i_@U-0#1~azs$m_H(_qPp^! z7B8RI$*|v4dfmR#n>^a}nTpsAVP!GN_e68ENkK6Nhxq=;Y(<-O4UN;hoxtPg9qx-# zX>le7k@<2ctBDYpKN(I@*skBo8KjHRK)EJnZ}`bZrQ~kDj)ytB;Utg`OhK zBW%WQI8~s~bafLr&uJxsz5a^@--`pHH#W&A&1LnxV~)z;g!XxN#!^RvQ0~vHk+3Dd z^{1@w!Z@;U=*vfwg`;u_saKEL^u?#El{ZYPx#~&i&-JB6Hg)kI`ux@*%VQzf=QqL| zVCsgjS+RqTvPb_T(g5CC|+TN6Tgm-SCRTT+=gcwI2R$B~Hj;LyUV`Ag%)0U#7| zI z;?ACY%^e3!uX3G=?9Z1t^EJm^AcO+px3KkEQ>0HS1EJj^L@>`}EgR;|G!a*^X;zsT zJLV`K#<+*ASF!lR`@?xonDS;E*5Rya$NuYCT!@bgKJXKB1sf%Z9*=HqV@dZ5qzsNU zLXyi+jYUY@&4O>rBVhdkC2d&q}(PYyVc(bwBz~YWEAP{ zQ7fNS7uxibE2ZPq6sUTbs(Q4O2VSWn$MVUERX`QWs#G#kT@0FcsEWtPLQgelcFU#X zktAs&$K7hurZ1@?Cks^LV`Qja~xw4iUYN>y?|KUwkMY+gR8DpZfpk%iByVG*~VRZlU=u{m&go!fS}bgtU@Xkjzi zV}cdT9Mr0c{&8pazvq*y^h%I*4V^!+?(6MUb#Ft?2e1(G&EYOE)pgR)kA8@0bfo2o z>KO*O3V6mQqK|1D!g$d-Ld@$h5yW1mp$S{c(Y=FnTb+4$cB|1N=DAFSVoR6XuGlLy zURQ9P-Ff)l4rh!~Bizs+y6o6*!Q!1(FJTBvIBb9`6fQbNBCo#)i{}^`@xVBz1<&C) znP~~GW@{M`ZYZ|&_8-)7o&6Sk3&&Zc%)xq?x<<-)yw|w1n;Y=b@!UoD`z}zG&G+OHdx4OR^Fh^C*IkdOVtHG5IhQjKReqwYJ0^`c zZ`>VK-gS;L&Ka=a`zYi4df_^J$931P=vI%l{B zJMYiK8@jp`$_8xD3*+W)P^ED_0|KbZ#)UmhO{4IcUGX^(H1E5n?YhVO=^Jv5@HA5c zHWZ+0+EWhI&w^069+p~nL*DyRdG8m8^8;E%_57JO0gXTHu$uWZv26r@nq?%CXJV}u zf`W)(XPbr4v|0(`{VvFRUn=ik7jU8iA#FLRDHy%GjWzi5I+=hR-;sFbm-G<MXUoXf=;l(UW^sWjDH#l2SW}JZz zPpOkm;BYrGw?SEw)EdHwIT?=XAy#Q0!xOpBO1+a56`jnzQYDMRv1}j%Rgr-ze*J81e|b zt3Re3V#=fFbb0J+A#S5Io!AW)zIpjTk#6|hToa+;dlb6I=MH$vI)yVg4fKnr809B= zpWXWVV_uI!^X>ZQvBo@?TcLYG6_b1Ub2kl7puqfF12*%9@yR!VrBF^A7NGRneJeZJ zha(#U7E`;W1)*4a862tFcFU^58gAoX#BQ z+uC1{-472t-4D^g1xcxSNefU&DE;j~QGDvQQ&8QCH`Zke68-iBHlr|pBw{b+{^Xl# z>X-b`?_U*tt9HesHFPn(mfrUx3eLRnP0;FfPwV1u-ts$qgb1YmP@s1aT7=Lge|p#m zYeU^YK*pX+ftgSZLpy^m1fmOZzUk?LJs-Eaz8Sgw)F&K@lo!p<&(8}%93nrz{{weX BBC-Gg literal 0 HcmV?d00001 diff --git a/src/main/resources/data/computercraft/lua/rom/help/speaker.md b/src/main/resources/data/computercraft/lua/rom/help/speaker.md new file mode 100644 index 000000000..872f5dfc9 --- /dev/null +++ b/src/main/resources/data/computercraft/lua/rom/help/speaker.md @@ -0,0 +1,5 @@ +The speaker program plays audio files using speakers attached to this computer. + +## Examples: +- `speaker play example.dfpwm left` plays the "example.dfpwm" audio file using the speaker on the left of the computer. +- `speaker stop` stops any currently playing audio. diff --git a/src/main/resources/data/computercraft/lua/rom/modules/main/cc/audio/dfpwm.lua b/src/main/resources/data/computercraft/lua/rom/modules/main/cc/audio/dfpwm.lua new file mode 100644 index 000000000..f3824f9cb --- /dev/null +++ b/src/main/resources/data/computercraft/lua/rom/modules/main/cc/audio/dfpwm.lua @@ -0,0 +1,228 @@ +--[[- +Provides utilities for converting between streams of DFPWM audio data and a list of amplitudes. + +DFPWM (Dynamic Filter Pulse Width Modulation) is an audio codec designed by GreaseMonkey. It's a relatively compact +format compared to raw PCM data, only using 1 bit per sample, but is simple enough to simple enough to encode and decode +in real time. + +Typically DFPWM audio is read from @{fs.BinaryReadHandle|the filesystem} or a @{http.Response|a web request} as a +string, and converted a format suitable for @{speaker.playAudio}. + +## Encoding and decoding files +This modules exposes two key functions, @{make_decoder} and @{make_encoder}, which construct a new decoder or encoder. +The returned encoder/decoder is itself a function, which converts between the two kinds of data. + +These encoders and decoders have lots of hidden state, so you should be careful to use the same encoder or decoder for +a specific audio stream. Typically you will want to create a decoder for each stream of audio you read, and an encoder +for each one you write. + +## Converting audio to DFPWM +DFPWM is not a popular file format and so standard audio processing tools will not have an option to export to it. +Instead, you can convert audio files online using [music.madefor.cc] or with the [LionRay Wav Converter][LionRay] Java +application. + +[music.madefor.cc]: https://music.madefor.cc/ "DFPWM audio converter for Computronics and CC: Tweaked" +[LionRay]: https://github.com/gamax92/LionRay/ "LionRay Wav Converter " + +@see guide!speaker_audio Gives a more general introduction to audio processing and the speaker. +@see speaker.playAudio To play the decoded audio data. +@usage Reads "data/example.dfpwm" in chunks, decodes them and then doubles the speed of the audio. The resulting audio +is then re-encoded and saved to "speedy.dfpwm". This processed audio can then be played with the `speaker` program. + +```lua +local dfpwm = require("cc.audio.dfpwm") + +local encoder = dfpwm.make_encoder() +local decoder = dfpwm.make_decoder() + +local out = fs.open("speedy.dfpwm", "wb") +for input in io.lines("my_audio_track.dfpwm", 16 * 1024 * 2) do + local decoded = decoder(input) + local output = {} + + -- Read two samples at once and take the average. + for i = 1, #decoded, 2 do + local value_1, value_2 = decoded[i], decoded[i + 1] + output[(i + 1) / 2] = (value_1 + value_2) / 2 + end + + out.write(encoder(output)) + + sleep(0) -- This program takes a while to run, so we need to make sure we yield. +end +out.close() +``` +]] + +local expect = require "cc.expect".expect + +local char, byte, floor, band, rshift = string.char, string.byte, math.floor, bit32.band, bit32.arshift + +local PREC = 10 +local PREC_POW = 2 ^ PREC +local PREC_POW_HALF = 2 ^ (PREC - 1) +local STRENGTH_MIN = 2 ^ (PREC - 8 + 1) + +local function make_predictor() + local charge, strength, previous_bit = 0, 0, false + + return function(current_bit) + local target = current_bit and 127 or -128 + + local next_charge = charge + floor((strength * (target - charge) + PREC_POW_HALF) / PREC_POW) + if next_charge == charge and next_charge ~= target then + next_charge = next_charge + (current_bit and 1 or -1) + end + + local z = current_bit == previous_bit and PREC_POW - 1 or 0 + local next_strength = strength + if next_strength ~= z then next_strength = next_strength + (current_bit == previous_bit and 1 or -1) end + if next_strength < STRENGTH_MIN then next_strength = STRENGTH_MIN end + + charge, strength, previous_bit = next_charge, next_strength, current_bit + return charge + end +end + +--[[- Create a new encoder for converting PCM audio data into DFPWM. + +The returned encoder is itself a function. This function accepts a table of amplitude data between -128 and 127 and +returns the encoded DFPWM data. + +:::caution Reusing encoders +Encoders have lots of internal state which tracks the state of the current stream. If you reuse an encoder for multiple +streams, or use different encoders for the same stream, the resulting audio may not sound correct. +::: + +@treturn function(pcm: { number... }):string The encoder function +@see encode A helper function for encoding an entire file of audio at once. +]] +local function make_encoder() + local predictor = make_predictor() + local previous_charge = 0 + + return function(input) + expect(1, input, "table") + + local output, output_n = {}, 0 + for i = 1, #input, 8 do + local this_byte = 0 + for j = 0, 7 do + local inp_charge = floor(input[i + j] or 0) + if inp_charge > 127 or inp_charge < -128 then + error(("Amplitude at position %d was %d, but should be between -128 and 127"):format(i + j, inp_charge), 2) + end + + local current_bit = inp_charge > previous_charge or (inp_charge == previous_charge and inp_charge == 127) + this_byte = floor(this_byte / 2) + (current_bit and 128 or 0) + + previous_charge = predictor(current_bit) + end + + output_n = output_n + 1 + output[output_n] = char(this_byte) + end + + return table.concat(output, "", 1, output_n) + end +end + +--[[- Create a new decoder for converting DFPWM into PCM audio data. + +The returned decoder is itself a function. This function accepts a string and returns a table of amplitudes, each value +between -128 and 127. + +:::caution Reusing decoders +Decoders have lots of internal state which tracks the state of the current stream. If you reuse an decoder for multiple +streams, or use different decoders for the same stream, the resulting audio may not sound correct. +::: + +@treturn function(dfpwm: string):{ number... } The encoder function +@see decode A helper function for decoding an entire file of audio at once. + +@usage Reads "data/example.dfpwm" in blocks of 16KiB (the speaker can accept a maximum of 128×1024 samples), decodes +them and then plays them through the speaker. + +```lua +local dfpwm = require "cc.audio.dfpwm" +local speaker = peripheral.find("speaker") + +local decoder = dfpwm.make_decoder() +for input in io.lines("data/example.dfpwm", 16 * 1024 * 2) do + local decoded = decoder(input) + while not speaker.playAudio(output) do + os.pullEvent("speaker_audio_empty") + end +end +``` +]] +local function make_decoder() + local predictor = make_predictor() + local low_pass_charge = 0 + local previous_charge, previous_bit = 0, false + + return function (input, output) + expect(1, input, "string") + + local output, output_n = {}, 0 + for i = 1, #input do + local input_byte = byte(input, i) + for _ = 1, 8 do + local current_bit = band(input_byte, 1) ~= 0 + local charge = predictor(current_bit) + + local antijerk = charge + if current_bit ~= previous_bit then + antijerk = floor((charge + previous_charge + 1) / 2) + end + + previous_charge, previous_bit = charge, current_bit + + low_pass_charge = low_pass_charge + floor(((antijerk - low_pass_charge) * 140 + 0x80) / 256) + + output_n = output_n + 1 + output[output_n] = low_pass_charge + + input_byte = rshift(input_byte, 1) + end + end + + return output + end +end + +--[[- A convenience function for decoding a complete file of audio at once. + +This should only be used for short files. For larger files, one should read the file in chunks and process it using +@{make_decoder}. + +@tparam string input The DFPWM data to convert. +@treturn { number... } The produced amplitude data. +@see make_decoder +]] +local function decode(input) + expect(1, input, "string") + return make_decoder()(input) +end + +--[[- A convenience function for encoding a complete file of audio at once. + +This should only be used for complete pieces of audio. If you are writing writing multiple chunks to the same place, +you should use an encoder returned by @{make_encoder} instead. + +@tparam { number... } input The table of amplitude data. +@treturn string The encoded DFPWM data. +@see make_encoder +]] +local function encode(input) + expect(1, input, "table") + return make_encoder()(input) +end + +return { + make_encoder = make_encoder, + encode = encode, + + make_decoder = make_decoder, + decode = decode, +} diff --git a/src/main/resources/data/computercraft/lua/rom/programs/fun/speaker.lua b/src/main/resources/data/computercraft/lua/rom/programs/fun/speaker.lua new file mode 100644 index 000000000..a4269d462 --- /dev/null +++ b/src/main/resources/data/computercraft/lua/rom/programs/fun/speaker.lua @@ -0,0 +1,62 @@ +local function get_speakers(name) + if name then + local speaker = peripheral.wrap(name) + if speaker == nil then + error(("Speaker %q does not exist"):format(name), 0) + return + elseif not peripheral.hasType(name, "speaker") then + error(("%q is not a speaker"):format(name), 0) + end + + return { speaker } + else + local speakers = { peripheral.find("speaker") } + if #speakers == 0 then + error("No speakers attached", 0) + end + return speakers + end +end + + +local cmd = ... +if cmd == "stop" then + local _, name = ... + for _, speaker in pairs(get_speakers(name)) do speaker.stop() end +elseif cmd == "play" then + local _, file, name = ... + local speaker = get_speakers(name)[1] + + local handle, err + if http and file:match("^https?://") then + print("Downloading...") + handle, err = http.get{ url = file, binary = true } + else + handle, err = fs.open(file, "rb") + end + + if not handle then + printError("Could not play audio:") + error(err, 0) + end + + print("Playing " .. file) + + local decoder = require "cc.audio.dfpwm".make_decoder() + while true do + local chunk = handle.read(16 * 1024) + if not chunk then break end + + local buffer = decoder(chunk) + while not speaker.playAudio(buffer) do + os.pullEvent("speaker_audio_empty") + end + end + + handle.close() +else + local programName = arg[0] or fs.getName(shell.getRunningProgram()) + print("Usage:") + print(programName .. " play [speaker]") + print(programName .. " stop [speaker]") +end diff --git a/src/main/resources/data/computercraft/lua/rom/startup.lua b/src/main/resources/data/computercraft/lua/rom/startup.lua index 8c03b4852..e1200de55 100644 --- a/src/main/resources/data/computercraft/lua/rom/startup.lua +++ b/src/main/resources/data/computercraft/lua/rom/startup.lua @@ -115,6 +115,18 @@ shell.setCompletionFunction("rom/programs/fun/dj.lua", completion.build( { completion.choice, { "play", "play ", "stop " } }, completion.peripheral )) +shell.setCompletionFunction("rom/programs/fun/speaker.lua", completion.build( + { completion.choice, { "play ", "stop " } }, + function(shell, text, previous) + if previous[2] == "play" then return completion.file(shell, text, previous, true) + elseif previous[2] == "stop" then return completion.peripheral(shell, text, previous, false) + end + end, + function(shell, text, previous) + if previous[2] == "play" then return completion.peripheral(shell, text, previous, false) + end + end +)) shell.setCompletionFunction("rom/programs/fun/advanced/paint.lua", completion.build(completion.file)) shell.setCompletionFunction("rom/programs/http/pastebin.lua", completion.build( { completion.choice, { "put ", "get ", "run " } }, diff --git a/src/test/java/dan200/computercraft/shared/peripheral/speaker/DfpwmStateTest.java b/src/test/java/dan200/computercraft/shared/peripheral/speaker/DfpwmStateTest.java new file mode 100644 index 000000000..f0ee68c03 --- /dev/null +++ b/src/test/java/dan200/computercraft/shared/peripheral/speaker/DfpwmStateTest.java @@ -0,0 +1,39 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.shared.peripheral.speaker; + +import dan200.computercraft.api.lua.LuaException; +import dan200.computercraft.api.lua.ObjectLuaTable; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +class DfpwmStateTest +{ + @Test + public void testEncoder() throws LuaException + { + int[] input = new int[] { 4, 4, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -3, -3, -3, -4, -4, -4, -4, -4, -5, -5, -5, -5, -5, -6, -6, -6, -7, -7, -7, -7, -7, -7, -7, -7, -7, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -7, -7, -7, -7, -7, -7, -7, -7, -7, -6, -6, -6, -6, -6, -6, -6, -6, -6, -5, -5, -5, -5, -5, -5, -5, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -3, -3, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -4, -4, -4, -4, -4, -5, -5, -5, -5, -5, -5, -5, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -7, -7, -7, -7, -7, -7, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -4, -4, -4, -4, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -4, -4, -4, -4, -4, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -5, -5, -5, -5, -5, -5, -5, -4, -4, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3 }; + Map inputTbl = new HashMap<>(); + for( int i = 0; i < input.length; i++ ) inputTbl.put( (double) (i + 1), input[i] ); + + DfpwmState state = new DfpwmState(); + state.pushBuffer( new ObjectLuaTable( inputTbl ), input.length, Optional.empty() ); + ByteBuffer result = state.pullPending( 0 ); + byte[] contents = new byte[result.remaining()]; + result.get( contents ); + + assertArrayEquals( + new byte[] { 87, 74, 42, -91, -92, -108, 84, -87, -86, 86, -83, 90, -83, -43, 90, -85, -42, 106, -43, -86, 106, -107, 42, -107, 74, -87, 74, -91, 74, -91, -86, -86, 106, 85, 107, -83, 106, -83, -83, 86, -75, -86, 42, 85, -107, 82, 41, -91, 82, 74, 41, -107, -86, -44, -86, 86, -75, 106, -83, -75, -86, -75, 90, -83, -86, -86, -86, 82, -91, 74, -107, -86, 82, -87, 82, 85, 85, 85, -83, 86, -75, -86, -43, 90, -83, 90, 85, 85, -107, 42, -91, 82, -86, 82, 74, 41, 85, -87, -86, -86, 106, -75, 90, -83, 86, -85, 106, -43, 106, 85, 85, 85, 85, -107, 42, 85, -86, 42, -107, -86, -86, -86, -86, 106, -75, -86, 86, -85 }, + contents + ); + } +} diff --git a/src/test/kotlin/dan200/computercraft/client/sound/DfpwmStreamTest.java b/src/test/kotlin/dan200/computercraft/client/sound/DfpwmStreamTest.java new file mode 100644 index 000000000..d4cf8d44d --- /dev/null +++ b/src/test/kotlin/dan200/computercraft/client/sound/DfpwmStreamTest.java @@ -0,0 +1,40 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.client.sound; + +import io.netty.buffer.ByteBuf; +import io.netty.buffer.ByteBufAllocator; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +public class DfpwmStreamTest +{ + @Test + public void testDecodesBytes() + { + DfpwmStream stream = new DfpwmStream(); + + ByteBuf input = ByteBufAllocator.DEFAULT.buffer(); + input.writeBytes( new byte[] { 43, -31, 33, 44, 30, -16, -85, 23, -3, -55, 46, -70, 68, -67, 74, -96, -68, 16, 94, -87, -5, 87, 11, -16, 19, 92, 85, -71, 126, 5, -84, 64, 17, -6, 85, -11, -1, -87, -12, 1, 85, -56, 33, -80, 82, 104, -93, 17, 126, 23, 91, -30, 37, -32, 117, -72, -58, 11, -76, 19, -108, 86, -65, -10, -1, -68, -25, 10, -46, 85, 124, -54, 15, -24, 43, -94, 117, 63, -36, 15, -6, 88, 87, -26, -83, 106, 41, 13, -28, -113, -10, -66, 119, -87, -113, 68, -55, 40, -107, 62, 20, 72, 3, -96, 114, -87, -2, 39, -104, 30, 20, 42, 84, 24, 47, 64, 43, 61, -35, 95, -65, 42, 61, 42, -50, 4, -9, 81 } ); + stream.push( input ); + + byte[] values = new byte[1024]; + ByteBuffer buffer = stream.read( 2048 ); + assertEquals( 1024, buffer.remaining(), "Must have read 1024 bytes" ); + buffer.get( values ); + assertEquals( 0, buffer.remaining() ); + + assertArrayEquals( + new byte[] { -127, -126, -126, -126, -126, -126, -126, -127, -127, -127, -128, 127, 126, 126, 127, -128, -127, -128, 127, 125, 123, 123, 123, 121, 119, 117, 117, 119, 119, 119, 119, 118, 116, 116, 118, 120, 122, 122, 120, 118, 116, 114, 112, 110, 111, 113, 116, 119, 122, 125, 126, 126, 126, 126, 126, 126, -128, -125, -122, -121, -121, -121, -124, -127, -127, -127, -127, -125, -123, -121, -119, -116, -113, -113, -116, -116, -116, -119, -119, -117, -116, -116, -114, -112, -111, -111, -111, -114, -117, -117, -117, -118, -116, -114, -114, -115, -115, -118, -119, -119, -121, -123, -124, -124, -124, -124, -124, -122, -120, -118, -118, -118, -118, -118, -118, -118, -119, -120, -120, -120, -121, -122, -124, -126, -128, -128, -128, -128, -128, 127, 127, -128, -127, -125, -125, -125, -125, -126, -128, 126, 126, 126, 125, 123, 121, 121, 123, 125, 127, 127, 127, 127, 127, 127, 126, 126, 127, 127, 127, 127, -128, -127, -127, -127, -126, -125, -124, -123, -122, -121, -119, -119, -119, -119, -119, -119, -119, -118, -118, -118, -118, -119, -120, -121, -122, -124, -126, -128, -128, -126, -124, -122, -120, -118, -118, -120, -121, -121, -123, -125, -127, 127, -128, -126, -124, -123, -123, -123, -124, -124, -124, -124, -124, -124, -124, -124, -124, -124, -125, -125, -124, -123, -123, -123, -123, -123, -122, -121, -120, -119, -118, -119, -119, -119, -119, -119, -120, -121, -122, -123, -125, -127, -127, -125, -125, -125, -125, -125, -125, -126, -127, -128, 127, 125, 125, 125, 125, 126, 125, 124, 124, 125, 124, 123, 122, 122, 123, 123, 124, 125, 126, -128, -126, -126, -126, -126, -126, -126, -126, -126, -126, -126, -126, -126, -126, -125, -124, -123, -122, -121, -120, -118, -116, -114, -112, -110, -108, -108, -111, -112, -112, -113, -113, -113, -113, -115, -115, -115, -115, -114, -113, -112, -110, -110, -112, -114, -116, -118, -120, -123, -123, -123, -124, -124, -124, -124, -124, -124, -126, -128, 126, 126, 126, 124, 124, 126, -128, -128, 126, 124, 122, 122, 122, 120, 118, 116, 114, 112, 113, 115, 116, 117, 117, 117, 117, 115, 115, 115, 115, 115, 114, 112, 110, 110, 110, 110, 112, 112, 112, 114, 115, 114, 113, 113, 114, 114, 116, 117, 116, 115, 115, 116, 115, 114, 113, 113, 115, 117, 119, 121, 123, 123, 123, 125, 127, 127, 127, 127, 125, 123, 123, 125, 125, 125, 127, 127, 127, 127, 125, 125, 125, 124, 122, 122, 124, 126, -128, -128, -128, -128, 126, 126, 126, 125, 123, 121, 119, 117, 115, 115, 117, 119, 121, 122, 122, 122, 122, 124, 126, 126, 124, 122, 120, 121, 123, 125, 126, 126, 126, 126, -128, -128, 126, 124, 124, 126, -128, -126, -126, -127, -127, 127, 125, 123, 121, 118, 118, 118, 118, 120, 121, 121, 123, 125, 126, 124, 124, 124, 122, 120, 118, 116, 116, 116, 116, 116, 114, 115, 115, 115, 117, 117, 117, 117, 117, 117, 117, 119, 121, 123, 125, 127, 127, 127, 127, 127, -127, -127, -127, -126, -124, -122, -120, -118, -116, -114, -112, -110, -108, -106, -106, -109, -110, -108, -106, -104, -105, -106, -104, -102, -100, -101, -104, -105, -103, -100, -100, -100, -101, -102, -102, -105, -108, -111, -114, -114, -114, -117, -117, -117, -117, -115, -113, -112, -112, -112, -113, -113, -114, -114, -116, -118, -119, -117, -115, -113, -111, -111, -114, -115, -115, -116, -116, -118, -119, -117, -115, -113, -111, -109, -109, -112, -115, -118, -121, -124, -127, -127, -126, -126, -124, -121, -118, -115, -115, -115, -116, -116, -116, -119, -122, -122, -122, -125, -128, -128, -128, -128, -126, -125, -125, -125, -125, -123, -121, -121, -121, -119, -117, -115, -113, -110, -110, -113, -116, -119, -120, -118, -115, -115, -115, -113, -110, -107, -104, -101, -101, -105, -109, -113, -117, -118, -119, -119, -116, -112, -109, -106, -105, -109, -114, -115, -112, -112, -113, -113, -114, -111, -108, -108, -109, -109, -110, -111, -114, -115, -113, -113, -116, -117, -115, -112, -109, -109, -110, -108, -108, -109, -110, -110, -111, -111, -112, -112, -112, -113, -111, -111, -112, -112, -115, -116, -116, -117, -117, -119, -119, -119, -119, -117, -117, -119, -121, -123, -125, -127, -127, -127, 127, 127, -127, -125, -123, -121, -119, -117, -116, -119, -122, -122, -122, -122, -120, -120, -121, -119, -117, -115, -115, -116, -114, -112, -110, -108, -108, -108, -106, -104, -102, -103, -103, -101, -99, -100, -101, -102, -105, -106, -106, -107, -107, -108, -106, -104, -102, -100, -101, -104, -107, -107, -107, -110, -111, -111, -114, -117, -117, -117, -118, -118, -121, -122, -122, -124, -125, -123, -123, -125, -127, -127, -127, -127, -127, 127, 127, 127, 127, 127, 127, -128, 127, 127, -128, -128, -127, -126, -125, -124, -125, -127, 127, 125, 125, 125, 125, 126, 125, 124, 122, 120, 118, 118, 118, 116, 116, 116, 116, 118, 118, 117, 116, 114, 112, 110, 108, 106, 104, 102, 100, 101, 101, 102, 102, 103, 103, 101, 102, 104, 106, 106, 106, 106, 104, 104, 104, 104, 105, 105, 106, 106, 107, 108, 109, 111, 113, 115, 117, 119, 121, 121, 119, 119, 119, 117, 115, 113, 111, 112, 114, 115, 113, 114, 114, 114, 116, 118, 120, 121, 119, 117, 115, 113, 114, 114, 115, 115, 113, 111, 109, 110, 110, 111, 111, 112, 112, 110, 108, 106, 107, 107, 107, 107, 107, 108, 107, 106, 104, 104, 106, 106, 104, 102, 103, 105, 107, 109, 110, 111, 111, 109, 107, 105, 103, 101, 99, 97, 98, 99, 100, 102, 103, 104, 104, 105, 105, 103, 104, 104, 104, 106, 108, 110, 110, 108, 108, 108, 108, 110, 112, 112, 112, 114, 116, 118, 120, 122, 124, 124, 124, 124, 124, 126, -128, -126, -124, -122, -122, -123, -123, -123, -123, -123, -123, -123, -123, -125, -125, -125, -125, -124, -123, -122, -123, -125, -127, -127, -127, -127, -127, -127, -127, -128, 127, 127, -128, -127, -127, -128, -128, -127, -127, -128, -128, -128, 127, 126, 125, 124, 124, 126, -128, -128, -128, -127, -125, -123, -121, -121, -123, -125, -125, -125, -125, -125 }, + values, + "Decoded values must match." + ); + } +} diff --git a/src/test/resources/test-rom/mcfly.lua b/src/test/resources/test-rom/mcfly.lua index 6c3bbc1df..a5caec7a8 100644 --- a/src/test/resources/test-rom/mcfly.lua +++ b/src/test/resources/test-rom/mcfly.lua @@ -189,13 +189,18 @@ end local expect_mt = {} expect_mt.__index = expect_mt +function expect_mt:_fail(message) + if self._extra then message = self._extra .. "\n" .. message end + fail(message) +end + --- Assert that this expectation has the provided value -- -- @param value The value to require this expectation to be equal to -- @throws If the values are not equal function expect_mt:equals(value) if value ~= self.value then - fail(("Expected %s\n but got %s"):format(format(value), format(self.value))) + self:_fail(("Expected %s\n but got %s"):format(format(value), format(self.value))) end return self @@ -209,7 +214,7 @@ expect_mt.eq = expect_mt.equals -- @throws If the values are equal function expect_mt:not_equals(value) if value == self.value then - fail(("Expected any value but %s"):format(format(value))) + self:_fail(("Expected any value but %s"):format(format(value))) end return self @@ -224,7 +229,7 @@ expect_mt.ne = expect_mt.not_equals function expect_mt:type(exp_type) local actual_type = type(self.value) if exp_type ~= actual_type then - fail(("Expected value of type %s\nbut got %s"):format(exp_type, actual_type)) + self:_fail(("Expected value of type %s\nbut got %s"):format(exp_type, actual_type)) end return self @@ -273,7 +278,7 @@ end -- @throws If they are not equivalent function expect_mt:same(value) if not matches({}, true, self.value, value) then - fail(("Expected %s\nbut got %s"):format(format(value), format(self.value))) + self:_fail(("Expected %s\nbut got %s"):format(format(value), format(self.value))) end return self @@ -286,7 +291,7 @@ end -- @throws If this does not match the provided value function expect_mt:matches(value) if not matches({}, false, value, self.value) then - fail(("Expected %s\nto match %s"):format(format(self.value), format(value))) + self:_fail(("Expected %s\nto match %s"):format(format(self.value), format(value))) end return self @@ -299,19 +304,19 @@ end -- @throws If this function was not called the expected number of times. function expect_mt:called(times) if getmetatable(self.value) ~= stub_mt or self.value.arguments == nil then - fail(("Expected stubbed function, got %s"):format(type(self.value))) + self:_fail(("Expected stubbed function, got %s"):format(type(self.value))) end local called = #self.value.arguments if times == nil then if called == 0 then - fail("Expected stub to be called\nbut it was not.") + self:_fail("Expected stub to be called\nbut it was not.") end else check('stub', 1, 'number', times) if called ~= times then - fail(("Expected stub to be called %d times\nbut was called %d times."):format(times, called)) + self:_fail(("Expected stub to be called %d times\nbut was called %d times."):format(times, called)) end end @@ -320,7 +325,7 @@ end local function called_with_check(eq, self, ...) if getmetatable(self.value) ~= stub_mt or self.value.arguments == nil then - fail(("Expected stubbed function, got %s"):format(type(self.value))) + self:_fail(("Expected stubbed function, got %s"):format(type(self.value))) end local exp_args = table.pack(...) @@ -331,14 +336,14 @@ local function called_with_check(eq, self, ...) local head = ("Expected stub to be called with %s\nbut was"):format(format(exp_args)) if #actual_args == 0 then - fail(head .. " not called at all") + self:_fail(head .. " not called at all") elseif #actual_args == 1 then - fail(("%s called with %s."):format(head, format(actual_args[1]))) + self:_fail(("%s called with %s."):format(head, format(actual_args[1]))) else local lines = { head .. " called with:" } for i = 1, #actual_args do lines[i + 1] = " - " .. format(actual_args[i]) end - fail(table.concat(lines, "\n")) + self:_fail(table.concat(lines, "\n")) end end @@ -363,15 +368,24 @@ end function expect_mt:str_match(pattern) local actual_type = type(self.value) if actual_type ~= "string" then - fail(("Expected value of type string\nbut got %s"):format(actual_type)) + self:_fail(("Expected value of type string\nbut got %s"):format(actual_type)) end if not self.value:find(pattern) then - fail(("Expected %q\n to match pattern %q"):format(self.value, pattern)) + self:_fail(("Expected %q\n to match pattern %q"):format(self.value, pattern)) end return self end +--- Add extra information to this error message. +-- +-- @tparam string message Additional message to prepend in the case of failures. +-- @return The current +function expect_mt:describe(message) + self._extra = tostring(message) + return self +end + local expect = {} setmetatable(expect, expect) diff --git a/src/test/resources/test-rom/spec/modules/cc/audio/dfpwm_spec.lua b/src/test/resources/test-rom/spec/modules/cc/audio/dfpwm_spec.lua new file mode 100644 index 000000000..dbc28ca01 --- /dev/null +++ b/src/test/resources/test-rom/spec/modules/cc/audio/dfpwm_spec.lua @@ -0,0 +1,26 @@ +describe("cc.audio.dfpwm", function() + local dfpwm = require "cc.audio.dfpwm" + + describe("decode", function() + it("decodes some test data", function() + -- Look, I'm not proud of this. + local input = "\43\225\33\44\30\240\171\23\253\201\46\186\68\189\74\160\188\16\94\169\251\87\11\240\19\92\85\185\126\5\172\64\17\250\85\245\255\169\244\1\85\200\33\176\82\104\163\17\126\23\91\226\37\224\117\184\198\11\180\19\148\86\191\246\255\188\231\10\210\85\124\202\15\232\43\162\117\63\220\15\250\88\87\230\173\106\41\13\228\143\246\190\119\169\143\68\201\40\149\62\20\72\3\160\114\169\254\39\152\30\20\42\84\24\47\64\43\61\221\95\191\42\61\42\206\4\247\81" + local output = { 1, 2, 2, 2, 2, 2, 2, 1, 1, 1, 0, -1, -2, -2, -1, 0, 1, 0, -1, -3, -5, -5, -5, -7, -9, -11, -11, -9, -9, -9, -9, -10, -12, -12, -10, -8, -6, -6, -8, -10, -12, -14, -16, -18, -17, -15, -12, -9, -6, -3, -2, -2, -2, -2, -2, -2, 0, 3, 6, 7, 7, 7, 4, 1, 1, 1, 1, 3, 5, 7, 9, 12, 15, 15, 12, 12, 12, 9, 9, 11, 12, 12, 14, 16, 17, 17, 17, 14, 11, 11, 11, 10, 12, 14, 14, 13, 13, 10, 9, 9, 7, 5, 4, 4, 4, 4, 4, 6, 8, 10, 10, 10, 10, 10, 10, 10, 9, 8, 8, 8, 7, 6, 4, 2, 0, 0, 0, 0, 0, -1, -1, 0, 1, 3, 3, 3, 3, 2, 0, -2, -2, -2, -3, -5, -7, -7, -5, -3, -1, -1, -1, -1, -1, -1, -2, -2, -1, -1, -1, -1, 0, 1, 1, 1, 2, 3, 4, 5, 6, 7, 9, 9, 9, 9, 9, 9, 9, 10, 10, 10, 10, 9, 8, 7, 6, 4, 2, 0, 0, 2, 4, 6, 8, 10, 10, 8, 7, 7, 5, 3, 1, -1, 0, 2, 4, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 4, 5, 5, 5, 5, 5, 6, 7, 8, 9, 10, 9, 9, 9, 9, 9, 8, 7, 6, 5, 3, 1, 1, 3, 3, 3, 3, 3, 3, 2, 1, 0, -1, -3, -3, -3, -3, -2, -3, -4, -4, -3, -4, -5, -6, -6, -5, -5, -4, -3, -2, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 3, 4, 5, 6, 7, 8, 10, 12, 14, 16, 18, 20, 20, 17, 16, 16, 15, 15, 15, 15, 13, 13, 13, 13, 14, 15, 16, 18, 18, 16, 14, 12, 10, 8, 5, 5, 5, 4, 4, 4, 4, 4, 4, 2, 0, -2, -2, -2, -4, -4, -2, 0, 0, -2, -4, -6, -6, -6, -8, -10, -12, -14, -16, -15, -13, -12, -11, -11, -11, -11, -13, -13, -13, -13, -13, -14, -16, -18, -18, -18, -18, -16, -16, -16, -14, -13, -14, -15, -15, -14, -14, -12, -11, -12, -13, -13, -12, -13, -14, -15, -15, -13, -11, -9, -7, -5, -5, -5, -3, -1, -1, -1, -1, -3, -5, -5, -3, -3, -3, -1, -1, -1, -1, -3, -3, -3, -4, -6, -6, -4, -2, 0, 0, 0, 0, -2, -2, -2, -3, -5, -7, -9, -11, -13, -13, -11, -9, -7, -6, -6, -6, -6, -4, -2, -2, -4, -6, -8, -7, -5, -3, -2, -2, -2, -2, 0, 0, -2, -4, -4, -2, 0, 2, 2, 1, 1, -1, -3, -5, -7, -10, -10, -10, -10, -8, -7, -7, -5, -3, -2, -4, -4, -4, -6, -8, -10, -12, -12, -12, -12, -12, -14, -13, -13, -13, -11, -11, -11, -11, -11, -11, -11, -9, -7, -5, -3, -1, -1, -1, -1, -1, 1, 1, 1, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 22, 19, 18, 20, 22, 24, 23, 22, 24, 26, 28, 27, 24, 23, 25, 28, 28, 28, 27, 26, 26, 23, 20, 17, 14, 14, 14, 11, 11, 11, 11, 13, 15, 16, 16, 16, 15, 15, 14, 14, 12, 10, 9, 11, 13, 15, 17, 17, 14, 13, 13, 12, 12, 10, 9, 11, 13, 15, 17, 19, 19, 16, 13, 10, 7, 4, 1, 1, 2, 2, 4, 7, 10, 13, 13, 13, 12, 12, 12, 9, 6, 6, 6, 3, 0, 0, 0, 0, 2, 3, 3, 3, 3, 5, 7, 7, 7, 9, 11, 13, 15, 18, 18, 15, 12, 9, 8, 10, 13, 13, 13, 15, 18, 21, 24, 27, 27, 23, 19, 15, 11, 10, 9, 9, 12, 16, 19, 22, 23, 19, 14, 13, 16, 16, 15, 15, 14, 17, 20, 20, 19, 19, 18, 17, 14, 13, 15, 15, 12, 11, 13, 16, 19, 19, 18, 20, 20, 19, 18, 18, 17, 17, 16, 16, 16, 15, 17, 17, 16, 16, 13, 12, 12, 11, 11, 9, 9, 9, 9, 11, 11, 9, 7, 5, 3, 1, 1, 1, -1, -1, 1, 3, 5, 7, 9, 11, 12, 9, 6, 6, 6, 6, 8, 8, 7, 9, 11, 13, 13, 12, 14, 16, 18, 20, 20, 20, 22, 24, 26, 25, 25, 27, 29, 28, 27, 26, 23, 22, 22, 21, 21, 20, 22, 24, 26, 28, 27, 24, 21, 21, 21, 18, 17, 17, 14, 11, 11, 11, 10, 10, 7, 6, 6, 4, 3, 5, 5, 3, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, 0, -1, -1, 0, 0, 1, 2, 3, 4, 3, 1, -1, -3, -3, -3, -3, -2, -3, -4, -6, -8, -10, -10, -10, -12, -12, -12, -12, -10, -10, -11, -12, -14, -16, -18, -20, -22, -24, -26, -28, -27, -27, -26, -26, -25, -25, -27, -26, -24, -22, -22, -22, -22, -24, -24, -24, -24, -23, -23, -22, -22, -21, -20, -19, -17, -15, -13, -11, -9, -7, -7, -9, -9, -9, -11, -13, -15, -17, -16, -14, -13, -15, -14, -14, -14, -12, -10, -8, -7, -9, -11, -13, -15, -14, -14, -13, -13, -15, -17, -19, -18, -18, -17, -17, -16, -16, -18, -20, -22, -21, -21, -21, -21, -21, -20, -21, -22, -24, -24, -22, -22, -24, -26, -25, -23, -21, -19, -18, -17, -17, -19, -21, -23, -25, -27, -29, -31, -30, -29, -28, -26, -25, -24, -24, -23, -23, -25, -24, -24, -24, -22, -20, -18, -18, -20, -20, -20, -20, -18, -16, -16, -16, -14, -12, -10, -8, -6, -4, -4, -4, -4, -4, -2, 0, 2, 4, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 3, 3, 3, 3, 4, 5, 6, 5, 3, 1, 1, 1, 1, 1, 1, 1, 0, -1, -1, 0, 1, 1, 0, 0, 1, 1, 0, 0, 0, -1, -2, -3, -4, -4, -2, 0, 0, 0, 1, 3, 5, 7, 7, 5, 3, 3, 3, 3, 3 } + + local decoded = dfpwm.decode(input) + expect(#decoded):describe("The lengths match"):eq(#output) + for i = 1, #decoded do expect(decoded[i]):describe("Item at #" .. i):eq(output[i]) end + end) + end) + + describe("encode", function() + it("encodes some data", function() + local input = { 4, 4, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -3, -3, -3, -4, -4, -4, -4, -4, -5, -5, -5, -5, -5, -6, -6, -6, -7, -7, -7, -7, -7, -7, -7, -7, -7, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -8, -7, -7, -7, -7, -7, -7, -7, -7, -7, -6, -6, -6, -6, -6, -6, -6, -6, -6, -5, -5, -5, -5, -5, -5, -5, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -3, -3, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -4, -4, -4, -4, -4, -5, -5, -5, -5, -5, -5, -5, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -7, -7, -7, -7, -7, -7, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -4, -4, -4, -4, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -3, -3, -3, -3, -3, -4, -4, -4, -4, -4, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -5, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -6, -5, -5, -5, -5, -5, -5, -5, -4, -4, -4, -4, -4, -4, -4, -3, -3, -3, -3, -3, -3, -3, -2, -2, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -1, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -2, -1, -1, -1, -1, -1, -1, -1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 3 } + local output = { 87, 74, 42, 165, 164, 148, 84, 169, 170, 86, 173, 90, 173, 213, 90, 171, 214, 106, 213, 170, 106, 149, 42, 149, 74, 169, 74, 165, 74, 165, 170, 170, 106, 85, 107, 173, 106, 173, 173, 86, 181, 170, 42, 85, 149, 82, 41, 165, 82, 74, 41, 149, 170, 212, 170, 86, 181, 106, 173, 181, 170, 181, 90, 173, 170, 170, 170, 82, 165, 74, 149, 170, 82, 169, 82, 85, 85, 85, 173, 86, 181, 170, 213, 90, 173, 90, 85, 85, 149, 42, 165, 82, 170, 82, 74, 41, 85, 169, 170, 170, 106, 181, 90, 173, 86, 171, 106, 213, 106, 85, 85, 85, 85, 149, 42, 85, 170, 42, 149, 170, 170, 170, 170, 106, 181, 170, 86, 171 } + + local encoded = dfpwm.encode(input) + expect(#encoded):describe("The lengths match"):eq(#output) + for i = 1, #encoded do expect(encoded:byte(i)):describe("Item at #" .. i):eq(output[i] % 256) end + end) + end) +end)