diff --git a/build.gradle b/build.gradle index 610be3970..4b1077441 100644 --- a/build.gradle +++ b/build.gradle @@ -6,6 +6,7 @@ } dependencies { classpath 'net.minecraftforge.gradle:ForgeGradle:5.1.+' + classpath "org.spongepowered:mixingradle:0.7.+" classpath 'org.parchmentmc:librarian:1.+' } } @@ -22,6 +23,7 @@ } apply plugin: 'net.minecraftforge.gradle' +apply plugin: "org.spongepowered.mixin" apply plugin: 'org.parchmentmc.librarian.forgegradle' version = mod_version @@ -72,6 +74,8 @@ source sourceSets.main } } + + arg "-mixin.config=computercraft.mixins.json" } client { @@ -129,6 +133,11 @@ accessTransformer file('src/main/resources/META-INF/accesstransformer.cfg') accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') } + +mixin { + add sourceSets.main, 'computercraft.mixins.refmap.json' +} + repositories { mavenCentral() maven { @@ -152,6 +161,7 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') checkstyle "com.puppycrawl.tools:checkstyle:8.45" minecraft "net.minecraftforge:forge:${mc_version}-${forge_version}" + annotationProcessor 'org.spongepowered:mixin:0.8.4:processor' // compileOnly fg.deobf("mezz.jei:jei-1.17.1:8.0.0.14:api") // runtimeOnly fg.deobf("mezz.jei:jei-1.17.1:8.0.0.14") @@ -170,7 +180,7 @@ accessTransformer file('src/testMod/resources/META-INF/accesstransformer.cfg') exclude group: "org.jetbrains", module: "annotations" } - cctJavadoc 'cc.tweaked:cct-javadoc:1.4.2' + cctJavadoc 'cc.tweaked:cct-javadoc:1.4.4' } // Compile tasks @@ -210,6 +220,8 @@ task luaJavadoc(type: Javadoc) { "Implementation-Version" : "${mod_version}", "Implementation-Vendor" : "SquidDev", "Implementation-Timestamp": new Date().format("yyyy-MM-dd'T'HH:mm:ssZ") + , + "MixinConfigs" : "computercraft.mixins.json", ]) } 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/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 4140597ec..7d2e6ec79 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 BlockEntity} (for instance {@literal minecraft:chest}). + * resulting peripheral uses the resource name of the wrapped {@link BlockEntity} (for instance {@code minecraft:chest}). * * However, in some cases it may be more appropriate to specify a more readable name. Overriding this method allows * you to do so. diff --git a/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java b/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java index c80526eda..d273b43ca 100644 --- a/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java +++ b/src/main/java/dan200/computercraft/api/peripheral/PeripheralType.java @@ -63,7 +63,7 @@ public static PeripheralType ofType( @Nonnull String type ) * Create a new non-empty peripheral type with additional traits. * * @param type The name of the type. - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofType( @Nonnull String type, Collection additionalTypes ) @@ -76,7 +76,7 @@ public static PeripheralType ofType( @Nonnull String type, Collection ad * Create a new non-empty peripheral type with additional traits. * * @param type The name of the type. - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofType( @Nonnull String type, @Nonnull String... additionalTypes ) @@ -88,7 +88,7 @@ public static PeripheralType ofType( @Nonnull String type, @Nonnull String... ad /** * Create a new peripheral type with no primary type but additional traits. * - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofAdditional( Collection additionalTypes ) @@ -99,7 +99,7 @@ public static PeripheralType ofAdditional( Collection additionalTypes ) /** * Create a new peripheral type with no primary type but additional traits. * - * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@literal "inventory"}. + * @param additionalTypes Additional types, or "traits" of this peripheral. For instance, {@code "inventory"}. * @return The constructed peripheral type. */ public static PeripheralType ofAdditional( @Nonnull String... additionalTypes ) @@ -108,7 +108,7 @@ public static PeripheralType ofAdditional( @Nonnull String... additionalTypes ) } /** - * Get the name of this peripheral type. This may be {@literal null}. + * Get the name of this peripheral type. This may be {@code null}. * * @return The type of this peripheral. */ diff --git a/src/main/java/dan200/computercraft/client/ClientHooks.java b/src/main/java/dan200/computercraft/client/ClientHooks.java index 87b60cc44..392b6de58 100644 --- a/src/main/java/dan200/computercraft/client/ClientHooks.java +++ b/src/main/java/dan200/computercraft/client/ClientHooks.java @@ -6,6 +6,7 @@ package dan200.computercraft.client; import dan200.computercraft.ComputerCraft; +import dan200.computercraft.client.sound.SpeakerManager; import dan200.computercraft.shared.peripheral.monitor.ClientMonitor; import net.minecraftforge.api.distmarker.Dist; import net.minecraftforge.client.event.ClientPlayerNetworkEvent; @@ -22,7 +23,7 @@ public static void onWorldUnload( WorldEvent.Unload event ) if( event.getWorld().isClientSide() ) { ClientMonitor.destroyAll(); - SoundManager.reset(); + SpeakerManager.reset(); } } diff --git a/src/main/java/dan200/computercraft/client/SoundManager.java b/src/main/java/dan200/computercraft/client/SoundManager.java deleted file mode 100644 index d4ccf7cac..000000000 --- a/src/main/java/dan200/computercraft/client/SoundManager.java +++ /dev/null @@ -1,84 +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.resources.sounds.AbstractSoundInstance; -import net.minecraft.client.resources.sounds.SoundInstance; -import net.minecraft.client.resources.sounds.TickableSoundInstance; -import net.minecraft.resources.ResourceLocation; -import net.minecraft.sounds.SoundSource; -import net.minecraft.world.phys.Vec3; - -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, Vec3 position, ResourceLocation event, float volume, float pitch ) - { - var 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 ) - { - SoundInstance sound = sounds.remove( source ); - if( sound == null ) return; - - Minecraft.getInstance().getSoundManager().stop( sound ); - } - - public static void moveSound( UUID source, Vec3 position ) - { - MoveableSound sound = sounds.get( source ); - if( sound != null ) sound.setPosition( position ); - } - - public static void reset() - { - sounds.clear(); - } - - private static class MoveableSound extends AbstractSoundInstance implements TickableSoundInstance - { - protected MoveableSound( ResourceLocation sound, Vec3 position, float volume, float pitch ) - { - super( sound, SoundSource.RECORDS ); - setPosition( position ); - this.volume = volume; - this.pitch = pitch; - attenuation = Attenuation.LINEAR; - } - - void setPosition( Vec3 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..db69c77a4 --- /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.sounds.AudioStream; +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 AudioStream +{ + 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..039ab3888 --- /dev/null +++ b/src/main/java/dan200/computercraft/client/sound/SpeakerInstance.java @@ -0,0 +1,80 @@ +/* + * 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.resources.ResourceLocation; +import net.minecraft.world.phys.Vec3; + +/** + * 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( Vec3 position, float volume ) + { + var 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( Vec3 position, ResourceLocation location, float volume, float pitch ) + { + var 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( Vec3 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..7e71d6c3b --- /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.world.phys.Vec3; +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.getChannel().attachBufferStream( sound.stream ); + event.getChannel().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, Vec3 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..20dcc27a0 --- /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.resources.sounds.AbstractSoundInstance; +import net.minecraft.client.resources.sounds.TickableSoundInstance; +import net.minecraft.client.sounds.AudioStream; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.phys.Vec3; + +import javax.annotation.Nullable; + +public class SpeakerSound extends AbstractSoundInstance implements TickableSoundInstance +{ + DfpwmStream stream; + + SpeakerSound( ResourceLocation sound, DfpwmStream stream, Vec3 position, float volume, float pitch ) + { + super( sound, SoundSource.RECORDS ); + setPosition( position ); + this.stream = stream; + this.volume = volume; + this.pitch = pitch; + attenuation = Attenuation.LINEAR; + } + + void setPosition( Vec3 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 AudioStream getStream() + { + return stream; + } +} diff --git a/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java b/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java index e29a87837..b16844832 100644 --- a/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java +++ b/src/main/java/dan200/computercraft/core/apis/http/websocket/WebsocketCompressionHandler.java @@ -15,7 +15,7 @@ import static io.netty.handler.codec.http.websocketx.extensions.compression.PerMessageDeflateServerExtensionHandshaker.MAX_WINDOW_SIZE; /** - * An alternative to {@link WebSocketClientCompressionHandler} which supports the {@literal client_no_context_takeover} + * An alternative to {@link WebSocketClientCompressionHandler} which supports the {@code client_no_context_takeover} * extension. Makes CC slightly more flexible. */ @ChannelHandler.Sharable diff --git a/src/main/java/dan200/computercraft/core/lua/CobaltLuaMachine.java b/src/main/java/dan200/computercraft/core/lua/CobaltLuaMachine.java index ce78cf454..3652b5ef2 100644 --- a/src/main/java/dan200/computercraft/core/lua/CobaltLuaMachine.java +++ b/src/main/java/dan200/computercraft/core/lua/CobaltLuaMachine.java @@ -14,8 +14,8 @@ import dan200.computercraft.core.tracking.Tracking; import dan200.computercraft.core.tracking.TrackingField; import dan200.computercraft.shared.util.ThreadUtils; -import org.squiddev.cobalt.*; import org.squiddev.cobalt.LuaTable; +import org.squiddev.cobalt.*; import org.squiddev.cobalt.compiler.CompileException; import org.squiddev.cobalt.compiler.LoadState; import org.squiddev.cobalt.debug.DebugFrame; diff --git a/src/main/java/dan200/computercraft/mixin/BlockRenderDispatcherMixin.java b/src/main/java/dan200/computercraft/mixin/BlockRenderDispatcherMixin.java new file mode 100644 index 000000000..5c8c0c773 --- /dev/null +++ b/src/main/java/dan200/computercraft/mixin/BlockRenderDispatcherMixin.java @@ -0,0 +1,92 @@ +/* + * This file is part of ComputerCraft - http://www.computercraft.info + * Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission. + * Send enquiries to dratcliffe@gmail.com + */ +package dan200.computercraft.mixin; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.vertex.VertexConsumer; +import dan200.computercraft.shared.Registry; +import dan200.computercraft.shared.peripheral.modem.wired.BlockCable; +import dan200.computercraft.shared.peripheral.modem.wired.CableModemVariant; +import dan200.computercraft.shared.peripheral.modem.wired.CableShapes; +import dan200.computercraft.shared.util.WorldUtil; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.block.BlockModelShaper; +import net.minecraft.client.renderer.block.BlockRenderDispatcher; +import net.minecraft.client.renderer.block.ModelBlockRenderer; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.client.resources.model.BakedModel; +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.BlockAndTintGetter; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.phys.BlockHitResult; +import net.minecraft.world.phys.HitResult; +import net.minecraftforge.client.model.data.IModelData; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.Shadow; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +import java.util.Random; + +/** + * Provides custom block breaking progress for modems, so it only applies to the current part. + * + * @see BlockRenderDispatcher#renderBreakingTexture(BlockState, BlockPos, BlockAndTintGetter, PoseStack, VertexConsumer, IModelData) + */ +@Mixin( BlockRenderDispatcher.class ) +public class BlockRenderDispatcherMixin +{ + @Shadow + private final Random random; + @Shadow + private final BlockModelShaper blockModelShaper; + @Shadow + private final ModelBlockRenderer modelRenderer; + + public BlockRenderDispatcherMixin( Random random, BlockModelShaper blockModelShaper, ModelBlockRenderer modelRenderer ) + { + this.random = random; + this.blockModelShaper = blockModelShaper; + this.modelRenderer = modelRenderer; + } + + @Inject( + method = "name=/^renderBreakingTexture/ desc=/IModelData;\\)V$/", + at = @At( "HEAD" ), + cancellable = true, + require = 0 // This isn't critical functionality, so don't worry if we can't apply it. + ) + public void renderBlockDamage( + BlockState state, BlockPos pos, BlockAndTintGetter world, PoseStack pose, VertexConsumer buffers, IModelData modelData, + CallbackInfo info + ) + { + // Only apply to cables which have both a cable and modem + if( state.getBlock() != Registry.ModBlocks.CABLE.get() + || !state.getValue( BlockCable.CABLE ) + || state.getValue( BlockCable.MODEM ) == CableModemVariant.None + ) + { + return; + } + + HitResult hit = Minecraft.getInstance().hitResult; + if( hit == null || hit.getType() != HitResult.Type.BLOCK ) return; + BlockPos hitPos = ((BlockHitResult) hit).getBlockPos(); + + if( !hitPos.equals( pos ) ) return; + + info.cancel(); + BlockState newState = WorldUtil.isVecInside( CableShapes.getModemShape( state ), hit.getLocation().subtract( pos.getX(), pos.getY(), pos.getZ() ) ) + ? state.getBlock().defaultBlockState().setValue( BlockCable.MODEM, state.getValue( BlockCable.MODEM ) ) + : state.setValue( BlockCable.MODEM, CableModemVariant.None ); + + BakedModel model = blockModelShaper.getBlockModel( newState ); + long seed = newState.getSeed( pos ); + modelRenderer.tesselateBlock( world, model, newState, pos, pose, buffers, true, random, seed, OverlayTexture.NO_OVERLAY, modelData ); + } +} diff --git a/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java b/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java index ca77ad7c6..3670bce0e 100644 --- a/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java +++ b/src/main/java/dan200/computercraft/shared/network/NetworkHandler.java @@ -57,10 +57,11 @@ public static void setup() registerMainThread( 13, NetworkDirection.PLAY_TO_CLIENT, ComputerTerminalClientMessage.class, ComputerTerminalClientMessage::new ); registerMainThread( 14, NetworkDirection.PLAY_TO_CLIENT, PlayRecordClientMessage.class, PlayRecordClientMessage::new ); registerMainThread( 15, NetworkDirection.PLAY_TO_CLIENT, MonitorClientMessage.class, MonitorClientMessage::new ); - registerMainThread( 16, NetworkDirection.PLAY_TO_CLIENT, SpeakerPlayClientMessage.class, SpeakerPlayClientMessage::new ); - registerMainThread( 17, NetworkDirection.PLAY_TO_CLIENT, SpeakerStopClientMessage.class, SpeakerStopClientMessage::new ); - registerMainThread( 18, NetworkDirection.PLAY_TO_CLIENT, SpeakerMoveClientMessage.class, SpeakerMoveClientMessage::new ); - registerMainThread( 19, NetworkDirection.PLAY_TO_CLIENT, UploadResultMessage.class, UploadResultMessage::new ); + registerMainThread( 16, NetworkDirection.PLAY_TO_CLIENT, SpeakerAudioClientMessage.class, SpeakerAudioClientMessage::new ); + registerMainThread( 17, NetworkDirection.PLAY_TO_CLIENT, SpeakerMoveClientMessage.class, SpeakerMoveClientMessage::new ); + registerMainThread( 18, NetworkDirection.PLAY_TO_CLIENT, SpeakerPlayClientMessage.class, SpeakerPlayClientMessage::new ); + registerMainThread( 19, NetworkDirection.PLAY_TO_CLIENT, SpeakerStopClientMessage.class, SpeakerStopClientMessage::new ); + registerMainThread( 20, NetworkDirection.PLAY_TO_CLIENT, UploadResultMessage.class, UploadResultMessage::new ); registerMainThread( 20, NetworkDirection.PLAY_TO_CLIENT, UpgradesLoadedMessage.class, UpgradesLoadedMessage::new ); } 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..02e66f7e0 --- /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.FriendlyByteBuf; +import net.minecraft.world.phys.Vec3; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; +import net.minecraftforge.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 Vec3 pos; + private final ByteBuffer content; + private final float volume; + + public SpeakerAudioClientMessage( UUID source, Vec3 pos, float volume, ByteBuffer content ) + { + this.source = source; + this.pos = pos; + this.content = content; + this.volume = volume; + } + + public SpeakerAudioClientMessage( FriendlyByteBuf buf ) + { + source = buf.readUUID(); + pos = new Vec3( buf.readDouble(), buf.readDouble(), buf.readDouble() ); + volume = buf.readFloat(); + + SpeakerManager.getSound( source ).pushAudio( buf ); + content = null; + } + + @Override + public void toBytes( @Nonnull FriendlyByteBuf 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 0972daef0..8b88453d3 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.FriendlyByteBuf; import net.minecraft.world.phys.Vec3; @@ -53,6 +53,6 @@ public void toBytes( @Nonnull FriendlyByteBuf buf ) @OnlyIn( Dist.CLIENT ) public void handle( NetworkEvent.Context context ) { - SoundManager.moveSound( source, pos ); + SpeakerManager.moveSound( source, pos ); } } diff --git a/src/main/java/dan200/computercraft/shared/network/client/SpeakerPlayClientMessage.java b/src/main/java/dan200/computercraft/shared/network/client/SpeakerPlayClientMessage.java index e5ddabd18..34869d35c 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.FriendlyByteBuf; import net.minecraft.resources.ResourceLocation; @@ -66,6 +66,6 @@ public void toBytes( @Nonnull FriendlyByteBuf buf ) @OnlyIn( Dist.CLIENT ) public void handle( NetworkEvent.Context context ) { - SoundManager.playSound( source, pos, sound, volume, pitch ); + SpeakerManager.getSound( source ).playSound( pos, sound, volume, pitch ); } } diff --git a/src/main/java/dan200/computercraft/shared/network/client/SpeakerStopClientMessage.java b/src/main/java/dan200/computercraft/shared/network/client/SpeakerStopClientMessage.java index 99a66004c..1890eab8c 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.FriendlyByteBuf; import net.minecraftforge.api.distmarker.Dist; @@ -46,6 +46,6 @@ public void toBytes( @Nonnull FriendlyByteBuf buf ) @OnlyIn( Dist.CLIENT ) public void handle( NetworkEvent.Context context ) { - SoundManager.stopSound( source ); + SpeakerManager.stopSound( source ); } } diff --git a/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java b/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java index d11cbe4b4..0a62164cb 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/commandblock/CommandBlockPeripheral.java @@ -29,7 +29,7 @@ /** * This peripheral allows you to interact with command blocks. * - * Command blocks are only wrapped as peripherals if the {@literal enable_command_block} option is true within the + * Command blocks are only wrapped as peripherals if the {@code enable_command_block} option is true within the * config. * * This API is not the same as the {@link CommandAPI} API, which is exposed on command computers. diff --git a/src/main/java/dan200/computercraft/shared/peripheral/speaker/DfpwmState.java b/src/main/java/dan200/computercraft/shared/peripheral/speaker/DfpwmState.java new file mode 100644 index 000000000..2f95be839 --- /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.Mth; + +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 = Mth.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 4671e9b06..c6764e430 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java @@ -9,77 +9,176 @@ 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.ResourceLocationException; import net.minecraft.core.BlockPos; import net.minecraft.network.protocol.game.ClientboundCustomSoundPacket; import net.minecraft.resources.ResourceLocation; import net.minecraft.server.MinecraftServer; import net.minecraft.sounds.SoundSource; -import net.minecraft.util.Mth; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.state.properties.NoteBlockInstrument; import net.minecraft.world.phys.Vec3; 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 Vec3 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 ); + + Vec3 pos = getPosition(); + Level level = getLevel(); + if( level == null ) return; + MinecraftServer server = level.getServer(); + + synchronized( pendingNotes ) + { + for( PendingSound sound : pendingNotes ) + { + lastPlayTime = clock; + server.getPlayerList().broadcast( + null, pos.x, pos.y, pos.z, sound.volume * 16, level.dimension(), + new ClientboundCustomSoundPacket( sound.location, SoundSource.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 ), + level, 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 ) ), + getLevel().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 ) { Vec3 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 ), getLevel().getChunkAt( new BlockPos( position ) ) ); + syncedPosition( position ); } } } + @Nullable public abstract Level getLevel(); + @Nonnull public abstract Vec3 getPosition(); - protected abstract UUID getSource(); - - public boolean madeSound( long ticks ) + @Nonnull + public UUID getSource() { - return clock - lastPlayTime <= ticks; + return source; + } + + public boolean madeSound() + { + DfpwmState state = dfpwmState; + return clock - lastPlayTime <= 20 || (state != null && state.isPlaying()); } @Nonnull @@ -90,18 +189,81 @@ public String getType() } /** - * Plays a sound through the speaker. + * Plays a note block note through the speaker. * - * This plays sounds similar to the {@code /playsound} command in Minecraft. - * It takes the namespaced path of a sound (e.g. {@code minecraft:block.note_block.harp}) - * with an optional volume and speed multiplier, and plays it through the speaker. + * This takes the name of a note to play, as well as optionally the volume + * and pitch to play the note at. + * + * The pitch argument uses semitones as the unit. This directly maps to the + * number of clicks on a note block. For reference, 0, 12, and 24 map to F#, + * and 6 and 18 map to C. + * + * A maximum of 8 notes can be played in a single tick. If this limit is hit, this function will return + * {@literal false}. + * + * ### Valid instruments + * The speaker supports [all of Minecraft's noteblock instruments](https://minecraft.fandom.com/wiki/Note_Block#Instruments). + * These are: + * + * {@code "harp"}, {@code "basedrum"}, {@code "snare"}, {@code "hat"}, {@code "bass"}, @code "flute"}, + * {@code "bell"}, {@code "guitar"}, {@code "chime"}, {@code "xylophone"}, {@code "iron_xylophone"}, + * {@code "cow_bell"}, {@code "didgeridoo"}, {@code "bit"}, {@code "banjo"} and {@code "pling"}. + * + * @param context The Lua context + * @param instrumentA The instrument to use to play this note. + * @param volumeA The volume to play the note at, from 0.0 to 3.0. Defaults to 1.0. + * @param pitchA The pitch to play the note at in semitones, from 0 to 24. Defaults to 12. + * @return Whether the note could be played as the limit was reached. + * @throws LuaException If the instrument doesn't exist. + */ + @LuaFunction + public final boolean playNote( ILuaContext context, String instrumentA, Optional volumeA, Optional pitchA ) throws LuaException + { + float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) ); + float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) ); + + NoteBlockInstrument instrument = null; + for( NoteBlockInstrument testInstrument : NoteBlockInstrument.values() ) + { + if( testInstrument.getSerializedName().equalsIgnoreCase( instrumentA ) ) + { + instrument = testInstrument; + break; + } + } + + // Check if the note exists + if( instrument == null ) throw new LuaException( "Invalid instrument, \"" + instrument + "\"!" ); + + synchronized( pendingNotes ) + { + if( pendingNotes.size() >= ComputerCraft.maxNotesPerTick ) return false; + pendingNotes.add( new PendingSound( instrument.getSoundEvent().getRegistryName(), volume, (float) Math.pow( 2.0, (pitch - 12.0) / 12.0 ) ) ); + } + return true; + } + + /** + * Plays a Minecraft sound through the speaker. + * + * This takes the [name of a Minecraft sound](https://minecraft.fandom.com/wiki/Sounds.json), such as + * {@code "minecraft:block.note_block.harp"}, as well as an optional volume and pitch. + * + * Only one sound can be played at once. This function will return {@literal false} if another sound was started + * this tick, or if some {@link #playAudio audio} is still playing. * * @param context The Lua context * @param name The name of the sound to play. * @param volumeA The volume to play the sound at, from 0.0 to 3.0. Defaults to 1.0. * @param pitchA The speed to play the sound at, from 0.5 to 2.0. Defaults to 1.0. * @return Whether the sound could be played. - * @throws LuaException If the sound name couldn't be decoded. + * @throws LuaException If the sound name was invalid. + * @cc.usage Play a creeper hiss with the speaker. + * + *
{@code
+     * local speaker = peripheral.find("speaker")
+     * speaker.playSound("entity.creeper.primed")
+     * }
*/ @LuaFunction public final boolean playSound( ILuaContext context, String name, Optional volumeA, Optional pitchA ) throws LuaException @@ -119,89 +281,123 @@ public final boolean playSound( ILuaContext context, String name, Optional{@code + * local dfpwm = require("cc.audio.dfpwm") + * local speaker = peripheral.find("speaker") + * + * local decoder = dfpwm.make_decoder() + * for chunk in io.lines("data/example.dfpwm", 16 * 1024) do + * local buffer = decoder(chunk) + * + * while not speaker.playAudio(buffer) do + * os.pullEvent("speaker_audio_empty") + * end + * end + * } + * @cc.see cc.audio.dfpwm Provides utilities for decoding DFPWM audio files into a format which can be played by + * the speaker. + * @cc.see speaker_audio For a more complete introduction to the {@link #playAudio} function. + */ + @LuaFunction( unsafe = true ) + public final boolean playAudio( ILuaContext context, LuaTable audio, Optional volume ) throws LuaException + { + checkFinite( 1, volume.orElse( 0.0 ) ); + + // TODO: Use ArgumentHelpers instead? + int length = audio.length(); + if( length <= 0 ) throw new LuaException( "Cannot play empty audio" ); + if( length > 1024 * 16 * 8 ) throw new LuaException( "Audio data is too large" ); + + DfpwmState state; + synchronized( lock ) + { + if( dfpwmState == null || !dfpwmState.isPlaying() ) dfpwmState = new DfpwmState(); + state = dfpwmState; + + pendingSound = null; + } + + return state.pushBuffer( audio, length, volume ); + } + + /** + * Stop all audio being played by this speaker. + * + * This clears any audio that {@link #playAudio} had queued and stops the latest sound played by {@link #playSound}. + * + * @cc.since 1.100 */ @LuaFunction - public final synchronized boolean playNote( ILuaContext context, String name, Optional volumeA, Optional pitchA ) throws LuaException + public final void stop() { - float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) ); - float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) ); - - NoteBlockInstrument instrument = null; - for( NoteBlockInstrument testInstrument : NoteBlockInstrument.values() ) - { - if( testInstrument.getSerializedName().equalsIgnoreCase( name ) ) - { - instrument = testInstrument; - break; - } - } - - // Check if the note exists - if( instrument == null ) throw new LuaException( "Invalid instrument, \"" + name + "\"!" ); - - // If the resource location for note block notes changes, this method call will need to be updated - boolean success = playSound( context, instrument.getSoundEvent().getRegistryName(), volume, (float) Math.pow( 2.0, (pitch - 12.0) / 12.0 ), true ); - if( success ) notesThisTick.incrementAndGet(); - return success; + shouldStop = true; } - private synchronized boolean playSound( ILuaContext context, ResourceLocation name, float volume, float pitch, boolean isNote ) throws LuaException + private void syncedPosition( Vec3 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; } - - Level world = getLevel(); - Vec3 pos = getPosition(); - - float actualVolume = Mth.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 ClientboundCustomSoundPacket( name, SoundSource.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 df3a6d33a..2fd7a64f4 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/speaker/TileSpeaker.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/TileSpeaker.java @@ -21,7 +21,6 @@ import javax.annotation.Nonnull; import javax.annotation.Nullable; -import java.util.UUID; import static dan200.computercraft.shared.Capabilities.CAPABILITY_PERIPHERAL; @@ -29,7 +28,6 @@ public class TileSpeaker extends TileGeneric { private final SpeakerPeripheral peripheral; private LazyOptional peripheralCap; - private final UUID source = UUID.randomUUID(); public TileSpeaker( BlockEntityType type, BlockPos pos, BlockState state ) { @@ -48,7 +46,7 @@ public void setRemoved() super.setRemoved(); if( level != null && !level.isClientSide ) { - NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( source ) ); + NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( peripheral.getSource() ) ); } } @@ -87,6 +85,7 @@ public Level getLevel() return speaker.getLevel(); } + @Nonnull @Override public Vec3 getPosition() { @@ -94,12 +93,6 @@ public Vec3 getPosition() return new Vec3( 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 0cb70b348..4cce23ae7 100644 --- a/src/main/java/dan200/computercraft/shared/peripheral/speaker/UpgradeSpeakerPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/peripheral/speaker/UpgradeSpeakerPeripheral.java @@ -12,7 +12,6 @@ import net.minecraftforge.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. @@ -21,14 +20,6 @@ public abstract class UpgradeSpeakerPeripheral extends SpeakerPeripheral { public static final String ADJECTIVE = "upgrade.computercraft.speaker.adjective"; - private final UUID source = UUID.randomUUID(); - - @Override - protected final UUID getSource() - { - return source; - } - @Override public void detach( @Nonnull IComputerAccess computer ) { @@ -36,6 +27,6 @@ public void detach( @Nonnull IComputerAccess computer ) 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 e6bc09b2b..2d77c7c26 100644 --- a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeaker.java +++ b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeaker.java @@ -42,6 +42,6 @@ public void update( @Nonnull IPocketAccess access, @Nullable IPeripheral periphe } speaker.update(); - access.setLight( speaker.madeSound( 20 ) ? 0x3320fc : -1 ); + access.setLight( speaker.madeSound() ? 0x3320fc : -1 ); } } diff --git a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java index 1644873b7..7793edb33 100644 --- a/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java +++ b/src/main/java/dan200/computercraft/shared/pocket/peripherals/PocketSpeakerPeripheral.java @@ -10,6 +10,8 @@ import net.minecraft.world.level.Level; import net.minecraft.world.phys.Vec3; +import javax.annotation.Nonnull; + public class PocketSpeakerPeripheral extends UpgradeSpeakerPeripheral { private Level world = null; @@ -27,6 +29,7 @@ public Level getLevel() return world; } + @Nonnull @Override public Vec3 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 aa7e3da79..48d40526f 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 Level getLevel() return turtle.getLevel(); } + @Nonnull @Override public Vec3 getPosition() { 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/computercraft.mixins.json b/src/main/resources/computercraft.mixins.json new file mode 100644 index 000000000..1e6fb5def --- /dev/null +++ b/src/main/resources/computercraft.mixins.json @@ -0,0 +1,13 @@ +{ + "minVersion": "0.8", + "required": true, + "compatibilityLevel": "JAVA_8", + "refmap": "computercraft.mixins.refmap.json", + "package": "dan200.computercraft.mixin", + "client": [ + "BlockRenderDispatcherMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua b/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua index 83f4bb6ad..3bc55a936 100644 --- a/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua +++ b/src/main/resources/data/computercraft/lua/rom/apis/rednet.lua @@ -29,9 +29,9 @@ CHANNEL_REPEAT = 65533 -- greater or equal to this limit wrap around to 0. MAX_ID_CHANNELS = 65500 -local tReceivedMessages = {} -local tHostnames = {} -local nClearTimer +local received_messages = {} +local hostnames = {} +local prune_received_timer local function id_as_channel(id) return (id or os.getComputerID()) % MAX_ID_CHANNELS @@ -115,10 +115,10 @@ be @{rednet.open|opened} before sending is possible. Assuming the target was in range and also had a correctly opened modem, it may then use @{rednet.receive} to collect the message. -@tparam number nRecipient The ID of the receiving computer. +@tparam number recipient The ID of the receiving computer. @param message The message to send. This should not contain coroutines or functions, as they will be converted to @{nil}. -@tparam[opt] string sProtocol The "protocol" to send this message under. When +@tparam[opt] string protocol The "protocol" to send this message under. When using @{rednet.receive} one can filter to only receive messages sent under a particular protocol. @treturn boolean If this message was successfully sent (i.e. if rednet is @@ -131,41 +131,41 @@ actually _received_. rednet.send(2, "Hello from rednet!") ]] -function send(nRecipient, message, sProtocol) - expect(1, nRecipient, "number") - expect(3, sProtocol, "string", "nil") +function send(recipient, message, protocol) + expect(1, recipient, "number") + expect(3, protocol, "string", "nil") -- Generate a (probably) unique message ID -- We could do other things to guarantee uniqueness, but we really don't need to -- Store it to ensure we don't get our own messages back - local nMessageID = math.random(1, 2147483647) - tReceivedMessages[nMessageID] = os.clock() + 9.5 - if not nClearTimer then nClearTimer = os.startTimer(10) end + local message_id = math.random(1, 2147483647) + received_messages[message_id] = os.clock() + 9.5 + if not prune_received_timer then prune_received_timer = os.startTimer(10) end -- Create the message - local nReplyChannel = id_as_channel() - local tMessage = { - nMessageID = nMessageID, - nRecipient = nRecipient, + local reply_channel = id_as_channel() + local message_wrapper = { + nMessageID = message_id, + nRecipient = recipient, nSender = os.getComputerID(), message = message, - sProtocol = sProtocol, + sProtocol = protocol, } local sent = false - if nRecipient == os.getComputerID() then + if recipient == os.getComputerID() then -- Loopback to ourselves - os.queueEvent("rednet_message", os.getComputerID(), message, sProtocol) + os.queueEvent("rednet_message", os.getComputerID(), message_wrapper, protocol) sent = true else -- Send on all open modems, to the target and to repeaters - if nRecipient ~= CHANNEL_BROADCAST then - nRecipient = id_as_channel(nRecipient) + if recipient ~= CHANNEL_BROADCAST then + recipient = id_as_channel(recipient) end - for _, sModem in ipairs(peripheral.getNames()) do - if isOpen(sModem) then - peripheral.call(sModem, "transmit", nRecipient, nReplyChannel, tMessage) - peripheral.call(sModem, "transmit", CHANNEL_REPEAT, nReplyChannel, tMessage) + for _, modem in ipairs(peripheral.getNames()) do + if isOpen(modem) then + peripheral.call(modem, "transmit", recipient, reply_channel, message_wrapper) + peripheral.call(modem, "transmit", CHANNEL_REPEAT, reply_channel, message_wrapper) sent = true end end @@ -179,23 +179,23 @@ end -- -- @param message The message to send. This should not contain coroutines or -- functions, as they will be converted to @{nil}. --- @tparam[opt] string sProtocol The "protocol" to send this message under. When +-- @tparam[opt] string protocol The "protocol" to send this message under. When -- using @{rednet.receive} one can filter to only receive messages sent under a -- particular protocol. -- @see rednet.receive -- @changed 1.6 Added protocol parameter. -function broadcast(message, sProtocol) - expect(2, sProtocol, "string", "nil") - send(CHANNEL_BROADCAST, message, sProtocol) +function broadcast(message, protocol) + expect(2, protocol, "string", "nil") + send(CHANNEL_BROADCAST, message, protocol) end --[[- Wait for a rednet message to be received, or until `nTimeout` seconds have elapsed. -@tparam[opt] string sProtocolFilter The protocol the received message must be +@tparam[opt] string protocol_filter The protocol the received message must be sent with. If specified, any messages not sent under this protocol will be discarded. -@tparam[opt] number nTimeout The number of seconds to wait if no message is +@tparam[opt] number timeout The number of seconds to wait if no message is received. @treturn[1] number The computer which sent this message @return[1] The received message @@ -227,34 +227,34 @@ received. print(message) ]] -function receive(sProtocolFilter, nTimeout) +function receive(protocol_filter, timeout) -- The parameters used to be ( nTimeout ), detect this case for backwards compatibility - if type(sProtocolFilter) == "number" and nTimeout == nil then - sProtocolFilter, nTimeout = nil, sProtocolFilter + if type(protocol_filter) == "number" and timeout == nil then + protocol_filter, timeout = nil, protocol_filter end - expect(1, sProtocolFilter, "string", "nil") - expect(2, nTimeout, "number", "nil") + expect(1, protocol_filter, "string", "nil") + expect(2, timeout, "number", "nil") -- Start the timer local timer = nil - local sFilter = nil - if nTimeout then - timer = os.startTimer(nTimeout) - sFilter = nil + local event_filter = nil + if timeout then + timer = os.startTimer(timeout) + event_filter = nil else - sFilter = "rednet_message" + event_filter = "rednet_message" end -- Wait for events while true do - local sEvent, p1, p2, p3 = os.pullEvent(sFilter) - if sEvent == "rednet_message" then + local event, p1, p2, p3 = os.pullEvent(event_filter) + if event == "rednet_message" then -- Return the first matching rednet_message - local nSenderID, message, sProtocol = p1, p2, p3 - if sProtocolFilter == nil or sProtocol == sProtocolFilter then - return nSenderID, message, sProtocol + local sender_id, message, protocol = p1, p2, p3 + if protocol_filter == nil or protocol == protocol_filter then + return sender_id, message, protocol end - elseif sEvent == "timer" then + elseif event == "timer" then -- Return nil if we timeout if p1 == timer then return nil @@ -276,34 +276,34 @@ end -- "registering" themselves before doing so (eg while offline or part of a -- different network). -- --- @tparam string sProtocol The protocol this computer provides. --- @tparam string sHostname The name this protocol exposes for the given protocol. +-- @tparam string protocol The protocol this computer provides. +-- @tparam string hostname The name this protocol exposes for the given protocol. -- @throws If trying to register a hostname which is reserved, or currently in use. -- @see rednet.unhost -- @see rednet.lookup -- @since 1.6 -function host(sProtocol, sHostname) - expect(1, sProtocol, "string") - expect(2, sHostname, "string") - if sHostname == "localhost" then +function host(protocol, hostname) + expect(1, protocol, "string") + expect(2, hostname, "string") + if hostname == "localhost" then error("Reserved hostname", 2) end - if tHostnames[sProtocol] ~= sHostname then - if lookup(sProtocol, sHostname) ~= nil then + if hostnames[protocol] ~= hostname then + if lookup(protocol, hostname) ~= nil then error("Hostname in use", 2) end - tHostnames[sProtocol] = sHostname + hostnames[protocol] = hostname end end --- Stop @{rednet.host|hosting} a specific protocol, meaning it will no longer -- respond to @{rednet.lookup} requests. -- --- @tparam string sProtocol The protocol to unregister your self from. +-- @tparam string protocol The protocol to unregister your self from. -- @since 1.6 -function unhost(sProtocol) - expect(1, sProtocol, "string") - tHostnames[sProtocol] = nil +function unhost(protocol) + expect(1, protocol, "string") + hostnames[protocol] = nil end --- Search the local rednet network for systems @{rednet.host|hosting} the @@ -313,36 +313,36 @@ end -- If a hostname is specified, only one ID will be returned (assuming an exact -- match is found). -- --- @tparam string sProtocol The protocol to search for. --- @tparam[opt] string sHostname The hostname to search for. +-- @tparam string protocol The protocol to search for. +-- @tparam[opt] string hostname The hostname to search for. -- -- @treturn[1] { number }|nil A list of computer IDs hosting the given -- protocol, or @{nil} if none exist. -- @treturn[2] number|nil The computer ID with the provided hostname and protocol, -- or @{nil} if none exists. -- @since 1.6 -function lookup(sProtocol, sHostname) - expect(1, sProtocol, "string") - expect(2, sHostname, "string", "nil") +function lookup(protocol, hostname) + expect(1, protocol, "string") + expect(2, hostname, "string", "nil") -- Build list of host IDs - local tResults = nil - if sHostname == nil then - tResults = {} + local results = nil + if hostname == nil then + results = {} end -- Check localhost first - if tHostnames[sProtocol] then - if sHostname == nil then - table.insert(tResults, os.getComputerID()) - elseif sHostname == "localhost" or sHostname == tHostnames[sProtocol] then + if hostnames[protocol] then + if hostname == nil then + table.insert(results, os.getComputerID()) + elseif hostname == "localhost" or hostname == hostnames[protocol] then return os.getComputerID() end end if not isOpen() then - if tResults then - return table.unpack(tResults) + if results then + return table.unpack(results) end return nil end @@ -350,8 +350,8 @@ function lookup(sProtocol, sHostname) -- Broadcast a lookup packet broadcast({ sType = "lookup", - sProtocol = sProtocol, - sHostname = sHostname, + sProtocol = protocol, + sHostname = hostname, }, "dns") -- Start a timer @@ -362,30 +362,28 @@ function lookup(sProtocol, sHostname) local event, p1, p2, p3 = os.pullEvent() if event == "rednet_message" then -- Got a rednet message, check if it's the response to our request - local nSenderID, tMessage, sMessageProtocol = p1, p2, p3 - if sMessageProtocol == "dns" and type(tMessage) == "table" and tMessage.sType == "lookup response" then - if tMessage.sProtocol == sProtocol then - if sHostname == nil then - table.insert(tResults, nSenderID) - elseif tMessage.sHostname == sHostname then - return nSenderID + local sender_id, message, message_protocol = p1, p2, p3 + if message_protocol == "dns" and type(message) == "table" and message.sType == "lookup response" then + if message.sProtocol == protocol then + if hostname == nil then + table.insert(results, sender_id) + elseif message.sHostname == hostname then + return sender_id end end end - else + elseif event == "timer" and p1 == timer then -- Got a timer event, check it's the end of our timeout - if p1 == timer then - break - end + break end end - if tResults then - return table.unpack(tResults) + if results then + return table.unpack(results) end return nil end -local bRunning = false +local started = false --- Listen for modem messages and converts them into rednet messages, which may -- then be @{receive|received}. @@ -393,51 +391,51 @@ local bRunning = false -- This is automatically started in the background on computer startup, and -- should not be called manually. function run() - if bRunning then + if started then error("rednet is already running", 2) end - bRunning = true + started = true - while bRunning do - local sEvent, p1, p2, p3, p4 = os.pullEventRaw() - if sEvent == "modem_message" then + while true do + local event, p1, p2, p3, p4 = os.pullEventRaw() + if event == "modem_message" then -- Got a modem message, process it and add it to the rednet event queue - local sModem, nChannel, nReplyChannel, tMessage = p1, p2, p3, p4 - if nChannel == id_as_channel() or nChannel == CHANNEL_BROADCAST then - if type(tMessage) == "table" and type(tMessage.nMessageID) == "number" - and tMessage.nMessageID == tMessage.nMessageID and not tReceivedMessages[tMessage.nMessageID] - and ((tMessage.nRecipient and tMessage.nRecipient == os.getComputerID()) or nChannel == CHANNEL_BROADCAST) - and isOpen(sModem) + local modem, channel, reply_channel, message = p1, p2, p3, p4 + if channel == id_as_channel() or channel == CHANNEL_BROADCAST then + if type(message) == "table" and type(message.nMessageID) == "number" + and message.nMessageID == message.nMessageID and not received_messages[message.nMessageID] + and ((message.nRecipient and message.nRecipient == os.getComputerID()) or channel == CHANNEL_BROADCAST) + and isOpen(modem) then - tReceivedMessages[tMessage.nMessageID] = os.clock() + 9.5 - if not nClearTimer then nClearTimer = os.startTimer(10) end - os.queueEvent("rednet_message", tMessage.nSender or nReplyChannel, tMessage.message, tMessage.sProtocol) + received_messages[message.nMessageID] = os.clock() + 9.5 + if not prune_received_timer then prune_received_timer = os.startTimer(10) end + os.queueEvent("rednet_message", message.nSender or reply_channel, message.message, message.sProtocol) end end - elseif sEvent == "rednet_message" then + elseif event == "rednet_message" then -- Got a rednet message (queued from above), respond to dns lookup - local nSenderID, tMessage, sProtocol = p1, p2, p3 - if sProtocol == "dns" and type(tMessage) == "table" and tMessage.sType == "lookup" then - local sHostname = tHostnames[tMessage.sProtocol] - if sHostname ~= nil and (tMessage.sHostname == nil or tMessage.sHostname == sHostname) then - rednet.send(nSenderID, { + local sender, message, protocol = p1, p2, p3 + if protocol == "dns" and type(message) == "table" and message.sType == "lookup" then + local hostname = hostnames[message.sProtocol] + if hostname ~= nil and (message.sHostname == nil or message.sHostname == hostname) then + send(sender, { sType = "lookup response", - sHostname = sHostname, - sProtocol = tMessage.sProtocol, + sHostname = hostname, + sProtocol = message.sProtocol, }, "dns") end end - elseif sEvent == "timer" and p1 == nClearTimer then - -- Got a timer event, use it to clear the event queue - nClearTimer = nil - local nNow, bHasMore = os.clock(), nil - for nMessageID, nDeadline in pairs(tReceivedMessages) do - if nDeadline <= nNow then tReceivedMessages[nMessageID] = nil - else bHasMore = true end + elseif event == "timer" and p1 == prune_received_timer then + -- Got a timer event, use it to prune the set of received messages + prune_received_timer = nil + local now, has_more = os.clock(), nil + for message_id, deadline in pairs(received_messages) do + if deadline <= now then received_messages[message_id] = nil + else has_more = true end end - nClearTimer = bHasMore and os.startTimer(10) + prune_received_timer = has_more and os.startTimer(10) end end end diff --git a/src/main/resources/data/computercraft/lua/rom/apis/term.lua b/src/main/resources/data/computercraft/lua/rom/apis/term.lua index 2def0eb50..125d7c408 100644 --- a/src/main/resources/data/computercraft/lua/rom/apis/term.lua +++ b/src/main/resources/data/computercraft/lua/rom/apis/term.lua @@ -59,7 +59,8 @@ end -- @treturn Redirect The current terminal redirect -- @since 1.6 -- @usage --- Create a new @{window} which draws to the current redirect target +-- Create a new @{window} which draws to the current redirect target. +-- -- window.create(term.current(), 1, 1, 10, 10) term.current = function() return redirectTarget diff --git a/src/main/resources/data/computercraft/lua/rom/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/programs/rednet/repeat.lua b/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua index c5ed89984..e1e0b9340 100644 --- a/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua +++ b/src/main/resources/data/computercraft/lua/rom/programs/rednet/repeat.lua @@ -14,10 +14,6 @@ else print(#tModems .. " modems found.") end -local function idAsChannel(id) - return (id or os.getComputerID()) % rednet.MAX_ID_CHANNELS -end - local function open(nChannel) for n = 1, #tModems do local sModem = tModems[n] @@ -53,11 +49,16 @@ local ok, error = pcall(function() tReceivedMessages[tMessage.nMessageID] = true tReceivedMessageTimeouts[os.startTimer(30)] = tMessage.nMessageID + local recipient_channel = tMessage.nRecipient + if tMessage.nRecipient ~= rednet.CHANNEL_BROADCAST then + recipient_channel = recipient_channel % rednet.MAX_ID_CHANNELS + end + -- Send on all other open modems, to the target and to other repeaters for n = 1, #tModems do local sOtherModem = tModems[n] peripheral.call(sOtherModem, "transmit", rednet.CHANNEL_REPEAT, nReplyChannel, tMessage) - peripheral.call(sOtherModem, "transmit", idAsChannel(tMessage.nRecipient), nReplyChannel, tMessage) + peripheral.call(sOtherModem, "transmit", recipient_channel, nReplyChannel, tMessage) end -- Log the event diff --git a/src/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/apis/rednet_spec.lua b/src/test/resources/test-rom/spec/apis/rednet_spec.lua index de5a85b60..35cb4c1df 100644 --- a/src/test/resources/test-rom/spec/apis/rednet_spec.lua +++ b/src/test/resources/test-rom/spec/apis/rednet_spec.lua @@ -88,6 +88,7 @@ describe("The rednet library", function() local fake_computer = require "support.fake_computer" local debugx = require "support.debug_ext" + local function dawdle() while true do coroutine.yield() end end local function computer_with_rednet(id, fn, options) local computer = fake_computer.make_computer(id, function(env) local fns = { env.rednet.run } @@ -105,6 +106,10 @@ describe("The rednet library", function() end end + if options and options.host then + env.rednet.host("some_protocol", "host_" .. id) + end + return parallel.waitForAny(table.unpack(fns)) end) local modem = fake_computer.add_modem(computer, "back") @@ -203,8 +208,8 @@ describe("The rednet library", function() env.sleep(10) -- Ensure our pending message store is empty. Bit ugly to prod internals, but there's no other way. - expect(debugx.getupvalue(rednet.run, "tReceivedMessages")):same({}) - expect(debugx.getupvalue(rednet.run, "nClearTimer")):eq(nil) + expect(debugx.getupvalue(rednet.run, "received_messages")):same({}) + expect(debugx.getupvalue(rednet.run, "prune_received_timer")):eq(nil) end, { open = true }) local computer_3, modem_3 = computer_with_rednet(3, nil, { open = true, rep = true }) @@ -222,5 +227,22 @@ describe("The rednet library", function() fake_computer.advance_all(computers, 10) fake_computer.run_all(computers, { computer_1, computer_2 }) end) + + it("handles lookups between computers with massive IDs", function() + local id_1, id_3 = 24283947, 93428798 + local computer_1, modem_1 = computer_with_rednet(id_1, function(rednet) + local ids = { rednet.lookup("some_protocol") } + expect(ids):same { id_3 } + end, { open = true }) + local computer_2, modem_2 = computer_with_rednet(2, nil, { open = true, rep = true }) + local computer_3, modem_3 = computer_with_rednet(id_3, dawdle, { open = true, host = true }) + fake_computer.add_modem_edge(modem_1, modem_2) + fake_computer.add_modem_edge(modem_2, modem_3) + + local computers = { computer_1, computer_2, computer_3 } + fake_computer.run_all(computers, false) + fake_computer.advance_all(computers, 3) + fake_computer.run_all(computers, { computer_1 }) + end) end) end) 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)