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 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 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 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. */ 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 class ClientHooks 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 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 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 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 ) 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 class SpeakerMoveClientMessage implements NetworkMessage @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 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 ); } } 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 class SpeakerStopClientMessage implements NetworkMessage @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 @@ 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 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.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 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 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 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 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. + * + *
{@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 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 peripheralCap; - private final UUID source = UUID.randomUUID(); public TileSpeaker( TileEntityType 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 ) { 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.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() ) ); } } 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 class PocketSpeaker extends AbstractPocketUpgrade } 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 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() { 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 class TurtleSpeaker extends AbstractTurtleUpgrade 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 000000000..d3f6aa988 Binary files /dev/null and b/src/main/resources/assets/computercraft/sounds/empty.ogg differ 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)