1
0
mirror of https://github.com/SquidDev-CC/CC-Tweaked synced 2024-12-13 19:50:31 +00:00

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!
This commit is contained in:
Jonathan Coates 2021-12-13 22:56:59 +00:00 committed by GitHub
parent e16f66e128
commit b048b6666d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1507 additions and 238 deletions

View File

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

200
doc/guides/speaker_audio.md Normal file
View File

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

View File

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

View File

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

View File

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

View File

@ -63,7 +63,7 @@ public final class PeripheralType
* 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<String> additionalTypes )
@ -76,7 +76,7 @@ public final class PeripheralType
* 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 final class PeripheralType
/**
* 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<String> additionalTypes )
@ -99,7 +99,7 @@ public final class PeripheralType
/**
* 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 final class PeripheralType
}
/**
* 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.
*/

View File

@ -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 class ClientHooks
if( event.getWorld().isClientSide() )
{
ClientMonitor.destroyAll();
SoundManager.reset();
SpeakerManager.reset();
}
}

View File

@ -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<UUID, MoveableSound> 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()
{
}
}
}

View File

@ -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<ByteBuffer> 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();
}
}

View File

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

View File

@ -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<UUID, SpeakerInstance> 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();
}
}

View File

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

View File

@ -15,7 +15,7 @@ import io.netty.handler.codec.http.websocketx.extensions.compression.WebSocketCl
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 <em>slightly</em> more flexible.
*/
@ChannelHandler.Sharable

View File

@ -57,10 +57,11 @@ public final class NetworkHandler
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 )

View File

@ -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 );
}
}

View File

@ -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 class SpeakerMoveClientMessage implements NetworkMessage
@OnlyIn( Dist.CLIENT )
public void handle( NetworkEvent.Context context )
{
SoundManager.moveSound( source, pos );
SpeakerManager.moveSound( source, pos );
}
}

View File

@ -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 class SpeakerPlayClientMessage implements NetworkMessage
@OnlyIn( Dist.CLIENT )
public void handle( NetworkEvent.Context context )
{
SoundManager.playSound( source, pos, sound, volume, pitch );
SpeakerManager.getSound( source ).playSound( pos, sound, volume, pitch );
}
}

View File

@ -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 class SpeakerStopClientMessage implements NetworkMessage
@OnlyIn( Dist.CLIENT )
public void handle( NetworkEvent.Context context )
{
SoundManager.stopSound( source );
SpeakerManager.stopSound( source );
}
}

View File

@ -29,7 +29,7 @@ import static dan200.computercraft.shared.Capabilities.CAPABILITY_PERIPHERAL;
/**
* 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 <em>not</em> the same as the {@link CommandAPI} API, which is exposed on command computers.

View File

@ -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<Double> 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;
}
}

View File

@ -9,10 +9,14 @@ import dan200.computercraft.ComputerCraft;
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.ResourceLocation;
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<IComputerAccess> 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<PendingSound> 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 abstract class SpeakerPeripheral implements IPeripheral
}
/**
* 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<Double> volumeA, Optional<Double> 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.
*
* <pre>{@code
* local speaker = peripheral.find("speaker")
* speaker.playSound("entity.creeper.primed")
* }</pre>
*/
@LuaFunction
public final boolean playSound( ILuaContext context, String name, Optional<Double> volumeA, Optional<Double> pitchA ) throws LuaException
@ -119,89 +281,123 @@ public abstract class SpeakerPeripheral implements IPeripheral
throw new LuaException( "Malformed sound name '" + name + "' " );
}
return playSound( context, identifier, volume, pitch, false );
synchronized( lock )
{
if( dfpwmState != null && dfpwmState.isPlaying() ) return false;
dfpwmState = null;
pendingSound = new PendingSound( identifier, volume, pitch );
return true;
}
}
/**
* Plays a note block note through the speaker.
* Attempt to stream some audio data to the speaker.
*
* This takes the name of a note to play, as well as optionally the volume
* and pitch to play the note at.
* This accepts a list of audio samples as amplitudes between -128 and 127. These are stored in an internal buffer
* and played back at 48kHz. If this buffer is full, this function will return {@literal false}. You should wait for
* a @{speaker_audio_empty} event before trying again.
*
* 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.
* :::note
* The speaker only buffers a single call to {@link #playAudio} at once. This means if you try to play a small
* number of samples, you'll have a lot of stutter. You should try to play as many samples in one call as possible
* (up to 128×1024), as this reduces the chances of audio stuttering or halting, especially when the server or
* computer is lagging.
* :::
*
* @param context The Lua context
* @param name The name of the note to play.
* @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.
* @throws LuaException If the instrument doesn't exist.
* {@literal @}{speaker_audio} provides a more complete guide in to using speakers
*
* @param context The Lua context.
* @param audio The audio data to play.
* @param volume The volume to play this audio at.
* @return If there was room to accept this audio data.
* @throws LuaException If the audio data is malformed.
* @cc.tparam {number...} audio A list of amplitudes.
* @cc.tparam [opt] number volume The volume to play this audio at. If not given, defaults to the previous volume
* given to {@link #playAudio}.
* @cc.since 1.100
* @cc.usage Read an audio file, decode it using @{cc.audio.dfpwm}, and play it using the speaker.
*
* <pre>{@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
* }</pre>
* @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<Double> 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<Double> volumeA, Optional<Double> 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;
}
}

View File

@ -21,7 +21,6 @@ import net.minecraftforge.common.util.LazyOptional;
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<IPeripheral> peripheralCap;
private final UUID source = UUID.randomUUID();
public TileSpeaker( TileEntityType<TileSpeaker> type )
{
@ -49,7 +47,7 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
super.setRemoved();
if( level != null && !level.isClientSide )
{
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( source ) );
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( peripheral.getSource() ) );
}
}
@ -88,6 +86,7 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
return speaker.getLevel();
}
@Nonnull
@Override
public Vector3d getPosition()
{
@ -95,12 +94,6 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
return new Vector3d( pos.getX(), pos.getY(), pos.getZ() );
}
@Override
protected UUID getSource()
{
return speaker.source;
}
@Override
public boolean equals( @Nullable IPeripheral other )
{

View File

@ -9,32 +9,22 @@ import dan200.computercraft.api.peripheral.IComputerAccess;
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() ) );
}
}

View File

@ -43,6 +43,6 @@ public class PocketSpeaker extends AbstractPocketUpgrade
}
speaker.update();
access.setLight( speaker.madeSound( 20 ) ? 0x3320fc : -1 );
access.setLight( speaker.madeSound() ? 0x3320fc : -1 );
}
}

View File

@ -10,6 +10,8 @@ import dan200.computercraft.shared.peripheral.speaker.UpgradeSpeakerPeripheral;
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 class PocketSpeakerPeripheral extends UpgradeSpeakerPeripheral
return world;
}
@Nonnull
@Override
public Vector3d getPosition()
{

View File

@ -43,6 +43,7 @@ public class TurtleSpeaker extends AbstractTurtleUpgrade
return turtle.getWorld();
}
@Nonnull
@Override
public Vector3d getPosition()
{

View File

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

View File

@ -0,0 +1,10 @@
{
"speaker.dfpwm_fake_audio_should_not_be_played": {
"sounds": [
{
"name": "computercraft:empty",
"stream": true
}
]
}
}

View File

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

View File

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

View File

@ -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 <file or url> [speaker]")
print(programName .. " stop [speaker]")
end

View File

@ -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 " } },

View File

@ -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<Object, Object> 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
);
}
}

File diff suppressed because one or more lines are too long

View File

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

View File

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