mirror of
https://github.com/SquidDev-CC/CC-Tweaked
synced 2025-11-18 06:05:12 +00:00
Add arbitrary audio support to speakers (#982)
Speakers can now play arbitrary PCM audio, sampled at 48kHz and with a resolution of 8 bits. Programs can build up buffers of audio locally, play it using `speaker.playAudio`, where it is encoded to DFPWM, sent across the network, decoded, and played on the client. `speaker.playAudio` may return false when a chunk of audio has been submitted but not yet sent to the client. In this case, the program should wait for a speaker_audio_empty event and try again, repeating until it works. While the API is a little odd, this gives us fantastic flexibility (we can play arbitrary streams of audio) while still being resilient in the presence of server lag (either TPS or on the computer thread). Some other notes: - There is a significant buffer on both the client and server, which means that sound take several seconds to finish after playing has started. One can force it to be stopped playing with the new `speaker.stop` call. - This also adds a `cc.audio.dfpwm` module, which allows encoding and decoding DFPWM1a audio files. - I spent so long writing the documentation for this. Who knows if it'll be helpful!
This commit is contained in:
@@ -57,10 +57,11 @@ public final class NetworkHandler
|
||||
registerMainThread( 13, NetworkDirection.PLAY_TO_CLIENT, ComputerTerminalClientMessage.class, ComputerTerminalClientMessage::new );
|
||||
registerMainThread( 14, NetworkDirection.PLAY_TO_CLIENT, PlayRecordClientMessage.class, PlayRecordClientMessage::new );
|
||||
registerMainThread( 15, NetworkDirection.PLAY_TO_CLIENT, MonitorClientMessage.class, MonitorClientMessage::new );
|
||||
registerMainThread( 16, NetworkDirection.PLAY_TO_CLIENT, SpeakerPlayClientMessage.class, SpeakerPlayClientMessage::new );
|
||||
registerMainThread( 17, NetworkDirection.PLAY_TO_CLIENT, SpeakerStopClientMessage.class, SpeakerStopClientMessage::new );
|
||||
registerMainThread( 18, NetworkDirection.PLAY_TO_CLIENT, SpeakerMoveClientMessage.class, SpeakerMoveClientMessage::new );
|
||||
registerMainThread( 19, NetworkDirection.PLAY_TO_CLIENT, UploadResultMessage.class, UploadResultMessage::new );
|
||||
registerMainThread( 16, NetworkDirection.PLAY_TO_CLIENT, SpeakerAudioClientMessage.class, SpeakerAudioClientMessage::new );
|
||||
registerMainThread( 17, NetworkDirection.PLAY_TO_CLIENT, SpeakerMoveClientMessage.class, SpeakerMoveClientMessage::new );
|
||||
registerMainThread( 18, NetworkDirection.PLAY_TO_CLIENT, SpeakerPlayClientMessage.class, SpeakerPlayClientMessage::new );
|
||||
registerMainThread( 19, NetworkDirection.PLAY_TO_CLIENT, SpeakerStopClientMessage.class, SpeakerStopClientMessage::new );
|
||||
registerMainThread( 20, NetworkDirection.PLAY_TO_CLIENT, UploadResultMessage.class, UploadResultMessage::new );
|
||||
}
|
||||
|
||||
public static void sendToPlayer( PlayerEntity player, NetworkMessage packet )
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* This file is part of ComputerCraft - http://www.computercraft.info
|
||||
* Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission.
|
||||
* Send enquiries to dratcliffe@gmail.com
|
||||
*/
|
||||
package dan200.computercraft.shared.network.client;
|
||||
|
||||
import dan200.computercraft.client.sound.SpeakerManager;
|
||||
import dan200.computercraft.shared.network.NetworkMessage;
|
||||
import net.minecraft.network.PacketBuffer;
|
||||
import net.minecraft.util.math.vector.Vector3d;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
import net.minecraftforge.api.distmarker.OnlyIn;
|
||||
import net.minecraftforge.fml.network.NetworkEvent;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Starts a sound on the client.
|
||||
*
|
||||
* Used by speakers to play sounds.
|
||||
*
|
||||
* @see dan200.computercraft.shared.peripheral.speaker.TileSpeaker
|
||||
*/
|
||||
public class SpeakerAudioClientMessage implements NetworkMessage
|
||||
{
|
||||
private final UUID source;
|
||||
private final Vector3d pos;
|
||||
private final ByteBuffer content;
|
||||
private final float volume;
|
||||
|
||||
public SpeakerAudioClientMessage( UUID source, Vector3d pos, float volume, ByteBuffer content )
|
||||
{
|
||||
this.source = source;
|
||||
this.pos = pos;
|
||||
this.content = content;
|
||||
this.volume = volume;
|
||||
}
|
||||
|
||||
public SpeakerAudioClientMessage( PacketBuffer buf )
|
||||
{
|
||||
source = buf.readUUID();
|
||||
pos = new Vector3d( buf.readDouble(), buf.readDouble(), buf.readDouble() );
|
||||
volume = buf.readFloat();
|
||||
|
||||
SpeakerManager.getSound( source ).pushAudio( buf );
|
||||
content = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void toBytes( @Nonnull PacketBuffer buf )
|
||||
{
|
||||
buf.writeUUID( source );
|
||||
buf.writeDouble( pos.x() );
|
||||
buf.writeDouble( pos.y() );
|
||||
buf.writeDouble( pos.z() );
|
||||
buf.writeFloat( volume );
|
||||
buf.writeBytes( content.duplicate() );
|
||||
}
|
||||
|
||||
@Override
|
||||
@OnlyIn( Dist.CLIENT )
|
||||
public void handle( NetworkEvent.Context context )
|
||||
{
|
||||
SpeakerManager.getSound( source ).playAudio( pos, volume );
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
package dan200.computercraft.shared.network.client;
|
||||
|
||||
import dan200.computercraft.client.SoundManager;
|
||||
import dan200.computercraft.client.sound.SpeakerManager;
|
||||
import dan200.computercraft.shared.network.NetworkMessage;
|
||||
import net.minecraft.network.PacketBuffer;
|
||||
import net.minecraft.util.math.vector.Vector3d;
|
||||
@@ -53,6 +53,6 @@ public class SpeakerMoveClientMessage implements NetworkMessage
|
||||
@OnlyIn( Dist.CLIENT )
|
||||
public void handle( NetworkEvent.Context context )
|
||||
{
|
||||
SoundManager.moveSound( source, pos );
|
||||
SpeakerManager.moveSound( source, pos );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
package dan200.computercraft.shared.network.client;
|
||||
|
||||
import dan200.computercraft.client.SoundManager;
|
||||
import dan200.computercraft.client.sound.SpeakerManager;
|
||||
import dan200.computercraft.shared.network.NetworkMessage;
|
||||
import net.minecraft.network.PacketBuffer;
|
||||
import net.minecraft.util.ResourceLocation;
|
||||
@@ -66,6 +66,6 @@ public class SpeakerPlayClientMessage implements NetworkMessage
|
||||
@OnlyIn( Dist.CLIENT )
|
||||
public void handle( NetworkEvent.Context context )
|
||||
{
|
||||
SoundManager.playSound( source, pos, sound, volume, pitch );
|
||||
SpeakerManager.getSound( source ).playSound( pos, sound, volume, pitch );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
package dan200.computercraft.shared.network.client;
|
||||
|
||||
import dan200.computercraft.client.SoundManager;
|
||||
import dan200.computercraft.client.sound.SpeakerManager;
|
||||
import dan200.computercraft.shared.network.NetworkMessage;
|
||||
import net.minecraft.network.PacketBuffer;
|
||||
import net.minecraftforge.api.distmarker.Dist;
|
||||
@@ -46,6 +46,6 @@ public class SpeakerStopClientMessage implements NetworkMessage
|
||||
@OnlyIn( Dist.CLIENT )
|
||||
public void handle( NetworkEvent.Context context )
|
||||
{
|
||||
SoundManager.stopSound( source );
|
||||
SpeakerManager.stopSound( source );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ import static dan200.computercraft.shared.Capabilities.CAPABILITY_PERIPHERAL;
|
||||
/**
|
||||
* This peripheral allows you to interact with command blocks.
|
||||
*
|
||||
* Command blocks are only wrapped as peripherals if the {@literal enable_command_block} option is true within the
|
||||
* Command blocks are only wrapped as peripherals if the {@code enable_command_block} option is true within the
|
||||
* config.
|
||||
*
|
||||
* This API is <em>not</em> the same as the {@link CommandAPI} API, which is exposed on command computers.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* This file is part of ComputerCraft - http://www.computercraft.info
|
||||
* Copyright Daniel Ratcliffe, 2011-2021. Do not distribute without permission.
|
||||
* Send enquiries to dratcliffe@gmail.com
|
||||
*/
|
||||
package dan200.computercraft.shared.peripheral.speaker;
|
||||
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaTable;
|
||||
import net.minecraft.util.math.MathHelper;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static dan200.computercraft.shared.peripheral.speaker.SpeakerPeripheral.SAMPLE_RATE;
|
||||
|
||||
/**
|
||||
* Internal state of the DFPWM decoder and the state of playback.
|
||||
*/
|
||||
class DfpwmState
|
||||
{
|
||||
private static final long SECOND = TimeUnit.SECONDS.toNanos( 1 );
|
||||
|
||||
/**
|
||||
* The minimum size of the client's audio buffer. Once we have less than this on the client, we should send another
|
||||
* batch of audio.
|
||||
*/
|
||||
private static final long CLIENT_BUFFER = (long) (SECOND * 1.5);
|
||||
|
||||
private static final int PREC = 10;
|
||||
|
||||
private int charge = 0; // q
|
||||
private int strength = 0; // s
|
||||
private boolean previousBit = false;
|
||||
|
||||
private boolean unplayed = true;
|
||||
private long clientEndTime = System.nanoTime();
|
||||
private float pendingVolume = 1.0f;
|
||||
private ByteBuffer pendingAudio;
|
||||
|
||||
synchronized boolean pushBuffer( LuaTable<?, ?> table, int size, @Nonnull Optional<Double> volume ) throws LuaException
|
||||
{
|
||||
if( pendingAudio != null ) return false;
|
||||
|
||||
int outSize = size / 8;
|
||||
ByteBuffer buffer = ByteBuffer.allocate( outSize );
|
||||
|
||||
for( int i = 0; i < outSize; i++ )
|
||||
{
|
||||
int thisByte = 0;
|
||||
for( int j = 1; j <= 8; j++ )
|
||||
{
|
||||
int level = table.getInt( i * 8 + j );
|
||||
if( level < -128 || level > 127 )
|
||||
{
|
||||
throw new LuaException( "table item #" + (i * 8 + j) + " must be between -128 and 127" );
|
||||
}
|
||||
|
||||
boolean currentBit = level > charge || (level == charge && charge == 127);
|
||||
|
||||
// Identical to DfpwmStream. Not happy with this, but saves some inheritance.
|
||||
int target = currentBit ? 127 : -128;
|
||||
|
||||
// q' <- q + (s * (t - q) + 128)/256
|
||||
int nextCharge = charge + ((strength * (target - charge) + (1 << (PREC - 1))) >> PREC);
|
||||
if( nextCharge == charge && nextCharge != target ) nextCharge += currentBit ? 1 : -1;
|
||||
|
||||
int z = currentBit == previousBit ? (1 << PREC) - 1 : 0;
|
||||
|
||||
int nextStrength = strength;
|
||||
if( strength != z ) nextStrength += currentBit == previousBit ? 1 : -1;
|
||||
if( nextStrength < 2 << (PREC - 8) ) nextStrength = 2 << (PREC - 8);
|
||||
|
||||
charge = nextCharge;
|
||||
strength = nextStrength;
|
||||
previousBit = currentBit;
|
||||
|
||||
thisByte = (thisByte >> 1) + (currentBit ? 128 : 0);
|
||||
}
|
||||
|
||||
buffer.put( (byte) thisByte );
|
||||
}
|
||||
|
||||
buffer.flip();
|
||||
|
||||
pendingAudio = buffer;
|
||||
pendingVolume = MathHelper.clamp( volume.orElse( (double) pendingVolume ).floatValue(), 0.0f, 3.0f );
|
||||
return true;
|
||||
}
|
||||
|
||||
boolean shouldSendPending( long now )
|
||||
{
|
||||
return pendingAudio != null && now >= clientEndTime - CLIENT_BUFFER;
|
||||
}
|
||||
|
||||
ByteBuffer pullPending( long now )
|
||||
{
|
||||
ByteBuffer audio = pendingAudio;
|
||||
pendingAudio = null;
|
||||
// Compute when we should consider sending the next packet.
|
||||
clientEndTime = Math.max( now, clientEndTime ) + (audio.remaining() * SECOND * 8 / SAMPLE_RATE);
|
||||
unplayed = false;
|
||||
return audio;
|
||||
}
|
||||
|
||||
boolean isPlaying()
|
||||
{
|
||||
return unplayed || clientEndTime >= System.nanoTime();
|
||||
}
|
||||
|
||||
float getVolume()
|
||||
{
|
||||
return pendingVolume;
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,14 @@ import dan200.computercraft.ComputerCraft;
|
||||
import dan200.computercraft.api.lua.ILuaContext;
|
||||
import dan200.computercraft.api.lua.LuaException;
|
||||
import dan200.computercraft.api.lua.LuaFunction;
|
||||
import dan200.computercraft.api.lua.LuaTable;
|
||||
import dan200.computercraft.api.peripheral.IComputerAccess;
|
||||
import dan200.computercraft.api.peripheral.IPeripheral;
|
||||
import dan200.computercraft.shared.network.NetworkHandler;
|
||||
import dan200.computercraft.shared.network.client.SpeakerAudioClientMessage;
|
||||
import dan200.computercraft.shared.network.client.SpeakerMoveClientMessage;
|
||||
import dan200.computercraft.shared.network.client.SpeakerPlayClientMessage;
|
||||
import dan200.computercraft.shared.network.client.SpeakerStopClientMessage;
|
||||
import net.minecraft.network.play.server.SPlaySoundPacket;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.state.properties.NoteBlockInstrument;
|
||||
@@ -20,66 +24,161 @@ import net.minecraft.util.ResourceLocation;
|
||||
import net.minecraft.util.ResourceLocationException;
|
||||
import net.minecraft.util.SoundCategory;
|
||||
import net.minecraft.util.math.BlockPos;
|
||||
import net.minecraft.util.math.MathHelper;
|
||||
import net.minecraft.util.math.vector.Vector3d;
|
||||
import net.minecraft.world.World;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.*;
|
||||
|
||||
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
|
||||
|
||||
/**
|
||||
* Speakers allow playing notes and other sounds.
|
||||
* The speaker peirpheral allow your computer to play notes and other sounds.
|
||||
*
|
||||
* The speaker can play three kinds of sound, in increasing orders of complexity:
|
||||
* - {@link #playNote} allows you to play noteblock note.
|
||||
* - {@link #playSound} plays any built-in Minecraft sound, such as block sounds or mob noises.
|
||||
* - {@link #playAudio} can play arbitrary audio.
|
||||
*
|
||||
* @cc.module speaker
|
||||
* @cc.since 1.80pr1
|
||||
*/
|
||||
public abstract class SpeakerPeripheral implements IPeripheral
|
||||
{
|
||||
private static final int MIN_TICKS_BETWEEN_SOUNDS = 1;
|
||||
/**
|
||||
* Number of samples/s in a dfpwm1a audio track.
|
||||
*/
|
||||
public static final int SAMPLE_RATE = 48000;
|
||||
|
||||
private final UUID source = UUID.randomUUID();
|
||||
private final Set<IComputerAccess> computers = Collections.newSetFromMap( new HashMap<>() );
|
||||
|
||||
private long clock = 0;
|
||||
private long lastPlayTime = 0;
|
||||
private final AtomicInteger notesThisTick = new AtomicInteger();
|
||||
|
||||
private long lastPositionTime;
|
||||
private Vector3d lastPosition;
|
||||
|
||||
private long lastPlayTime;
|
||||
|
||||
private final List<PendingSound> pendingNotes = new ArrayList<>();
|
||||
|
||||
private final Object lock = new Object();
|
||||
private boolean shouldStop;
|
||||
private PendingSound pendingSound = null;
|
||||
private DfpwmState dfpwmState;
|
||||
|
||||
public void update()
|
||||
{
|
||||
clock++;
|
||||
notesThisTick.set( 0 );
|
||||
|
||||
Vector3d pos = getPosition();
|
||||
World world = getWorld();
|
||||
if( world == null ) return;
|
||||
MinecraftServer server = world.getServer();
|
||||
|
||||
synchronized( pendingNotes )
|
||||
{
|
||||
for( PendingSound sound : pendingNotes )
|
||||
{
|
||||
lastPlayTime = clock;
|
||||
server.getPlayerList().broadcast(
|
||||
null, pos.x, pos.y, pos.z, sound.volume * 16, world.dimension(),
|
||||
new SPlaySoundPacket( sound.location, SoundCategory.RECORDS, pos, sound.volume, sound.pitch )
|
||||
);
|
||||
}
|
||||
pendingNotes.clear();
|
||||
}
|
||||
|
||||
// The audio dispatch logic here is pretty messy, which I'm not proud of. The general logic here is that we hold
|
||||
// the main "lock" when modifying the dfpwmState/pendingSound variables and no other time.
|
||||
// dfpwmState will only ever transition from having a buffer to not having a buffer on the main thread (so this
|
||||
// method), so we don't need to bother locking that.
|
||||
boolean shouldStop;
|
||||
PendingSound sound;
|
||||
DfpwmState dfpwmState;
|
||||
synchronized( lock )
|
||||
{
|
||||
sound = pendingSound;
|
||||
dfpwmState = this.dfpwmState;
|
||||
pendingSound = null;
|
||||
|
||||
shouldStop = this.shouldStop;
|
||||
if( shouldStop )
|
||||
{
|
||||
dfpwmState = this.dfpwmState = null;
|
||||
sound = null;
|
||||
this.shouldStop = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the speaker and nuke the position, so we don't update it again.
|
||||
if( shouldStop && lastPosition != null )
|
||||
{
|
||||
lastPosition = null;
|
||||
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( getSource() ) );
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.nanoTime();
|
||||
if( sound != null )
|
||||
{
|
||||
lastPlayTime = clock;
|
||||
NetworkHandler.sendToAllAround(
|
||||
new SpeakerPlayClientMessage( getSource(), pos, sound.location, sound.volume, sound.pitch ),
|
||||
world, pos, sound.volume * 16
|
||||
);
|
||||
syncedPosition( pos );
|
||||
}
|
||||
else if( dfpwmState != null && dfpwmState.shouldSendPending( now ) )
|
||||
{
|
||||
// If clients need to receive another batch of audio, send it and then notify computers our internal buffer is
|
||||
// free again.
|
||||
NetworkHandler.sendToAllTracking(
|
||||
new SpeakerAudioClientMessage( getSource(), pos, dfpwmState.getVolume(), dfpwmState.pullPending( now ) ),
|
||||
getWorld().getChunkAt( new BlockPos( pos ) )
|
||||
);
|
||||
syncedPosition( pos );
|
||||
|
||||
// And notify computers that we have space for more audio.
|
||||
for( IComputerAccess computer : computers )
|
||||
{
|
||||
computer.queueEvent( "speaker_audio_empty", computer.getAttachmentName() );
|
||||
}
|
||||
}
|
||||
|
||||
// Push position updates to any speakers which have ever played a note,
|
||||
// have moved by a non-trivial amount and haven't had a position update
|
||||
// in the last second.
|
||||
if( lastPlayTime > 0 && (clock - lastPositionTime) >= 20 )
|
||||
if( lastPosition != null && (clock - lastPositionTime) >= 20 )
|
||||
{
|
||||
Vector3d position = getPosition();
|
||||
if( lastPosition == null || lastPosition.distanceToSqr( position ) >= 0.1 )
|
||||
if( lastPosition.distanceToSqr( position ) >= 0.1 )
|
||||
{
|
||||
lastPosition = position;
|
||||
lastPositionTime = clock;
|
||||
NetworkHandler.sendToAllTracking(
|
||||
new SpeakerMoveClientMessage( getSource(), position ),
|
||||
getWorld().getChunkAt( new BlockPos( position ) )
|
||||
);
|
||||
syncedPosition( position );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public abstract World getWorld();
|
||||
|
||||
@Nonnull
|
||||
public abstract Vector3d getPosition();
|
||||
|
||||
protected abstract UUID getSource();
|
||||
|
||||
public boolean madeSound( long ticks )
|
||||
@Nonnull
|
||||
public UUID getSource()
|
||||
{
|
||||
return clock - lastPlayTime <= ticks;
|
||||
return source;
|
||||
}
|
||||
|
||||
public boolean madeSound()
|
||||
{
|
||||
DfpwmState state = dfpwmState;
|
||||
return clock - lastPlayTime <= 20 || (state != null && state.isPlaying());
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@@ -90,18 +189,81 @@ public abstract class SpeakerPeripheral implements IPeripheral
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a sound through the speaker.
|
||||
* Plays a note block note through the speaker.
|
||||
*
|
||||
* This plays sounds similar to the {@code /playsound} command in Minecraft.
|
||||
* It takes the namespaced path of a sound (e.g. {@code minecraft:block.note_block.harp})
|
||||
* with an optional volume and speed multiplier, and plays it through the speaker.
|
||||
* This takes the name of a note to play, as well as optionally the volume
|
||||
* and pitch to play the note at.
|
||||
*
|
||||
* The pitch argument uses semitones as the unit. This directly maps to the
|
||||
* number of clicks on a note block. For reference, 0, 12, and 24 map to F#,
|
||||
* and 6 and 18 map to C.
|
||||
*
|
||||
* A maximum of 8 notes can be played in a single tick. If this limit is hit, this function will return
|
||||
* {@literal false}.
|
||||
*
|
||||
* ### Valid instruments
|
||||
* The speaker supports [all of Minecraft's noteblock instruments](https://minecraft.fandom.com/wiki/Note_Block#Instruments).
|
||||
* These are:
|
||||
*
|
||||
* {@code "harp"}, {@code "basedrum"}, {@code "snare"}, {@code "hat"}, {@code "bass"}, @code "flute"},
|
||||
* {@code "bell"}, {@code "guitar"}, {@code "chime"}, {@code "xylophone"}, {@code "iron_xylophone"},
|
||||
* {@code "cow_bell"}, {@code "didgeridoo"}, {@code "bit"}, {@code "banjo"} and {@code "pling"}.
|
||||
*
|
||||
* @param context The Lua context
|
||||
* @param instrumentA The instrument to use to play this note.
|
||||
* @param volumeA The volume to play the note at, from 0.0 to 3.0. Defaults to 1.0.
|
||||
* @param pitchA The pitch to play the note at in semitones, from 0 to 24. Defaults to 12.
|
||||
* @return Whether the note could be played as the limit was reached.
|
||||
* @throws LuaException If the instrument doesn't exist.
|
||||
*/
|
||||
@LuaFunction
|
||||
public final boolean playNote( ILuaContext context, String instrumentA, Optional<Double> volumeA, Optional<Double> pitchA ) throws LuaException
|
||||
{
|
||||
float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) );
|
||||
float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) );
|
||||
|
||||
NoteBlockInstrument instrument = null;
|
||||
for( NoteBlockInstrument testInstrument : NoteBlockInstrument.values() )
|
||||
{
|
||||
if( testInstrument.getSerializedName().equalsIgnoreCase( instrumentA ) )
|
||||
{
|
||||
instrument = testInstrument;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the note exists
|
||||
if( instrument == null ) throw new LuaException( "Invalid instrument, \"" + instrument + "\"!" );
|
||||
|
||||
synchronized( pendingNotes )
|
||||
{
|
||||
if( pendingNotes.size() >= ComputerCraft.maxNotesPerTick ) return false;
|
||||
pendingNotes.add( new PendingSound( instrument.getSoundEvent().getRegistryName(), volume, (float) Math.pow( 2.0, (pitch - 12.0) / 12.0 ) ) );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a Minecraft sound through the speaker.
|
||||
*
|
||||
* This takes the [name of a Minecraft sound](https://minecraft.fandom.com/wiki/Sounds.json), such as
|
||||
* {@code "minecraft:block.note_block.harp"}, as well as an optional volume and pitch.
|
||||
*
|
||||
* Only one sound can be played at once. This function will return {@literal false} if another sound was started
|
||||
* this tick, or if some {@link #playAudio audio} is still playing.
|
||||
*
|
||||
* @param context The Lua context
|
||||
* @param name The name of the sound to play.
|
||||
* @param volumeA The volume to play the sound at, from 0.0 to 3.0. Defaults to 1.0.
|
||||
* @param pitchA The speed to play the sound at, from 0.5 to 2.0. Defaults to 1.0.
|
||||
* @return Whether the sound could be played.
|
||||
* @throws LuaException If the sound name couldn't be decoded.
|
||||
* @throws LuaException If the sound name was invalid.
|
||||
* @cc.usage Play a creeper hiss with the speaker.
|
||||
*
|
||||
* <pre>{@code
|
||||
* local speaker = peripheral.find("speaker")
|
||||
* speaker.playSound("entity.creeper.primed")
|
||||
* }</pre>
|
||||
*/
|
||||
@LuaFunction
|
||||
public final boolean playSound( ILuaContext context, String name, Optional<Double> volumeA, Optional<Double> pitchA ) throws LuaException
|
||||
@@ -119,89 +281,123 @@ public abstract class SpeakerPeripheral implements IPeripheral
|
||||
throw new LuaException( "Malformed sound name '" + name + "' " );
|
||||
}
|
||||
|
||||
return playSound( context, identifier, volume, pitch, false );
|
||||
synchronized( lock )
|
||||
{
|
||||
if( dfpwmState != null && dfpwmState.isPlaying() ) return false;
|
||||
dfpwmState = null;
|
||||
pendingSound = new PendingSound( identifier, volume, pitch );
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a note block note through the speaker.
|
||||
* Attempt to stream some audio data to the speaker.
|
||||
*
|
||||
* This takes the name of a note to play, as well as optionally the volume
|
||||
* and pitch to play the note at.
|
||||
* This accepts a list of audio samples as amplitudes between -128 and 127. These are stored in an internal buffer
|
||||
* and played back at 48kHz. If this buffer is full, this function will return {@literal false}. You should wait for
|
||||
* a @{speaker_audio_empty} event before trying again.
|
||||
*
|
||||
* The pitch argument uses semitones as the unit. This directly maps to the
|
||||
* number of clicks on a note block. For reference, 0, 12, and 24 map to F#,
|
||||
* and 6 and 18 map to C.
|
||||
* :::note
|
||||
* The speaker only buffers a single call to {@link #playAudio} at once. This means if you try to play a small
|
||||
* number of samples, you'll have a lot of stutter. You should try to play as many samples in one call as possible
|
||||
* (up to 128×1024), as this reduces the chances of audio stuttering or halting, especially when the server or
|
||||
* computer is lagging.
|
||||
* :::
|
||||
*
|
||||
* @param context The Lua context
|
||||
* @param name The name of the note to play.
|
||||
* @param volumeA The volume to play the note at, from 0.0 to 3.0. Defaults to 1.0.
|
||||
* @param pitchA The pitch to play the note at in semitones, from 0 to 24. Defaults to 12.
|
||||
* @return Whether the note could be played.
|
||||
* @throws LuaException If the instrument doesn't exist.
|
||||
* {@literal @}{speaker_audio} provides a more complete guide in to using speakers
|
||||
*
|
||||
* @param context The Lua context.
|
||||
* @param audio The audio data to play.
|
||||
* @param volume The volume to play this audio at.
|
||||
* @return If there was room to accept this audio data.
|
||||
* @throws LuaException If the audio data is malformed.
|
||||
* @cc.tparam {number...} audio A list of amplitudes.
|
||||
* @cc.tparam [opt] number volume The volume to play this audio at. If not given, defaults to the previous volume
|
||||
* given to {@link #playAudio}.
|
||||
* @cc.since 1.100
|
||||
* @cc.usage Read an audio file, decode it using @{cc.audio.dfpwm}, and play it using the speaker.
|
||||
*
|
||||
* <pre>{@code
|
||||
* local dfpwm = require("cc.audio.dfpwm")
|
||||
* local speaker = peripheral.find("speaker")
|
||||
*
|
||||
* local decoder = dfpwm.make_decoder()
|
||||
* for chunk in io.lines("data/example.dfpwm", 16 * 1024) do
|
||||
* local buffer = decoder(chunk)
|
||||
*
|
||||
* while not speaker.playAudio(buffer) do
|
||||
* os.pullEvent("speaker_audio_empty")
|
||||
* end
|
||||
* end
|
||||
* }</pre>
|
||||
* @cc.see cc.audio.dfpwm Provides utilities for decoding DFPWM audio files into a format which can be played by
|
||||
* the speaker.
|
||||
* @cc.see speaker_audio For a more complete introduction to the {@link #playAudio} function.
|
||||
*/
|
||||
@LuaFunction( unsafe = true )
|
||||
public final boolean playAudio( ILuaContext context, LuaTable<?, ?> audio, Optional<Double> volume ) throws LuaException
|
||||
{
|
||||
checkFinite( 1, volume.orElse( 0.0 ) );
|
||||
|
||||
// TODO: Use ArgumentHelpers instead?
|
||||
int length = audio.length();
|
||||
if( length <= 0 ) throw new LuaException( "Cannot play empty audio" );
|
||||
if( length > 1024 * 16 * 8 ) throw new LuaException( "Audio data is too large" );
|
||||
|
||||
DfpwmState state;
|
||||
synchronized( lock )
|
||||
{
|
||||
if( dfpwmState == null || !dfpwmState.isPlaying() ) dfpwmState = new DfpwmState();
|
||||
state = dfpwmState;
|
||||
|
||||
pendingSound = null;
|
||||
}
|
||||
|
||||
return state.pushBuffer( audio, length, volume );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all audio being played by this speaker.
|
||||
*
|
||||
* This clears any audio that {@link #playAudio} had queued and stops the latest sound played by {@link #playSound}.
|
||||
*
|
||||
* @cc.since 1.100
|
||||
*/
|
||||
@LuaFunction
|
||||
public final synchronized boolean playNote( ILuaContext context, String name, Optional<Double> volumeA, Optional<Double> pitchA ) throws LuaException
|
||||
public final void stop()
|
||||
{
|
||||
float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) );
|
||||
float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) );
|
||||
|
||||
NoteBlockInstrument instrument = null;
|
||||
for( NoteBlockInstrument testInstrument : NoteBlockInstrument.values() )
|
||||
{
|
||||
if( testInstrument.getSerializedName().equalsIgnoreCase( name ) )
|
||||
{
|
||||
instrument = testInstrument;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the note exists
|
||||
if( instrument == null ) throw new LuaException( "Invalid instrument, \"" + name + "\"!" );
|
||||
|
||||
// If the resource location for note block notes changes, this method call will need to be updated
|
||||
boolean success = playSound( context, instrument.getSoundEvent().getRegistryName(), volume, (float) Math.pow( 2.0, (pitch - 12.0) / 12.0 ), true );
|
||||
if( success ) notesThisTick.incrementAndGet();
|
||||
return success;
|
||||
shouldStop = true;
|
||||
}
|
||||
|
||||
private synchronized boolean playSound( ILuaContext context, ResourceLocation name, float volume, float pitch, boolean isNote ) throws LuaException
|
||||
private void syncedPosition( Vector3d position )
|
||||
{
|
||||
if( clock - lastPlayTime < MIN_TICKS_BETWEEN_SOUNDS )
|
||||
lastPosition = position;
|
||||
lastPositionTime = clock;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void attach( @Nonnull IComputerAccess computer )
|
||||
{
|
||||
computers.add( computer );
|
||||
}
|
||||
|
||||
@Override
|
||||
public void detach( @Nonnull IComputerAccess computer )
|
||||
{
|
||||
computers.remove( computer );
|
||||
}
|
||||
|
||||
private static final class PendingSound
|
||||
{
|
||||
final ResourceLocation location;
|
||||
final float volume;
|
||||
final float pitch;
|
||||
|
||||
private PendingSound( ResourceLocation location, float volume, float pitch )
|
||||
{
|
||||
// Rate limiting occurs when we've already played a sound within the last tick.
|
||||
if( !isNote ) return false;
|
||||
// Or we've played more notes than allowable within the current tick.
|
||||
if( clock - lastPlayTime != 0 || notesThisTick.get() >= ComputerCraft.maxNotesPerTick ) return false;
|
||||
this.location = location;
|
||||
this.volume = volume;
|
||||
this.pitch = pitch;
|
||||
}
|
||||
|
||||
World world = getWorld();
|
||||
Vector3d pos = getPosition();
|
||||
|
||||
float actualVolume = MathHelper.clamp( volume, 0.0f, 3.0f );
|
||||
float range = actualVolume * 16;
|
||||
|
||||
context.issueMainThreadTask( () -> {
|
||||
MinecraftServer server = world.getServer();
|
||||
if( server == null ) return null;
|
||||
|
||||
if( isNote )
|
||||
{
|
||||
server.getPlayerList().broadcast(
|
||||
null, pos.x, pos.y, pos.z, range, world.dimension(),
|
||||
new SPlaySoundPacket( name, SoundCategory.RECORDS, pos, actualVolume, pitch )
|
||||
);
|
||||
}
|
||||
else
|
||||
{
|
||||
NetworkHandler.sendToAllAround(
|
||||
new SpeakerPlayClientMessage( getSource(), pos, name, actualVolume, pitch ),
|
||||
world, pos, range
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} );
|
||||
|
||||
lastPlayTime = clock;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import net.minecraftforge.common.util.LazyOptional;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import javax.annotation.Nullable;
|
||||
import java.util.UUID;
|
||||
|
||||
import static dan200.computercraft.shared.Capabilities.CAPABILITY_PERIPHERAL;
|
||||
|
||||
@@ -29,7 +28,6 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
|
||||
{
|
||||
private final SpeakerPeripheral peripheral;
|
||||
private LazyOptional<IPeripheral> peripheralCap;
|
||||
private final UUID source = UUID.randomUUID();
|
||||
|
||||
public TileSpeaker( TileEntityType<TileSpeaker> type )
|
||||
{
|
||||
@@ -49,7 +47,7 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
|
||||
super.setRemoved();
|
||||
if( level != null && !level.isClientSide )
|
||||
{
|
||||
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( source ) );
|
||||
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( peripheral.getSource() ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,6 +86,7 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
|
||||
return speaker.getLevel();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Vector3d getPosition()
|
||||
{
|
||||
@@ -95,12 +94,6 @@ public class TileSpeaker extends TileGeneric implements ITickableTileEntity
|
||||
return new Vector3d( pos.getX(), pos.getY(), pos.getZ() );
|
||||
}
|
||||
|
||||
@Override
|
||||
protected UUID getSource()
|
||||
{
|
||||
return speaker.source;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals( @Nullable IPeripheral other )
|
||||
{
|
||||
|
||||
@@ -9,32 +9,22 @@ import dan200.computercraft.api.peripheral.IComputerAccess;
|
||||
import dan200.computercraft.shared.network.NetworkHandler;
|
||||
import dan200.computercraft.shared.network.client.SpeakerStopClientMessage;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraftforge.fml.LogicalSide;
|
||||
import net.minecraftforge.fml.LogicalSidedProvider;
|
||||
import net.minecraftforge.fml.server.ServerLifecycleHooks;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A speaker peripheral which is used on an upgrade, and so is only attached to one computer.
|
||||
*/
|
||||
public abstract class UpgradeSpeakerPeripheral extends SpeakerPeripheral
|
||||
{
|
||||
private final UUID source = UUID.randomUUID();
|
||||
|
||||
@Override
|
||||
protected final UUID getSource()
|
||||
{
|
||||
return source;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void detach( @Nonnull IComputerAccess computer )
|
||||
{
|
||||
// We could be in the process of shutting down the server, so we can't send packets in this case.
|
||||
MinecraftServer server = LogicalSidedProvider.INSTANCE.get( LogicalSide.SERVER );
|
||||
MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
|
||||
if( server == null || server.isStopped() ) return;
|
||||
|
||||
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( source ) );
|
||||
NetworkHandler.sendToAllPlayers( new SpeakerStopClientMessage( getSource() ) );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,6 @@ public class PocketSpeaker extends AbstractPocketUpgrade
|
||||
}
|
||||
|
||||
speaker.update();
|
||||
access.setLight( speaker.madeSound( 20 ) ? 0x3320fc : -1 );
|
||||
access.setLight( speaker.madeSound() ? 0x3320fc : -1 );
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import dan200.computercraft.shared.peripheral.speaker.UpgradeSpeakerPeripheral;
|
||||
import net.minecraft.util.math.vector.Vector3d;
|
||||
import net.minecraft.world.World;
|
||||
|
||||
import javax.annotation.Nonnull;
|
||||
|
||||
public class PocketSpeakerPeripheral extends UpgradeSpeakerPeripheral
|
||||
{
|
||||
private World world = null;
|
||||
@@ -27,6 +29,7 @@ public class PocketSpeakerPeripheral extends UpgradeSpeakerPeripheral
|
||||
return world;
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Vector3d getPosition()
|
||||
{
|
||||
|
||||
@@ -43,6 +43,7 @@ public class TurtleSpeaker extends AbstractTurtleUpgrade
|
||||
return turtle.getWorld();
|
||||
}
|
||||
|
||||
@Nonnull
|
||||
@Override
|
||||
public Vector3d getPosition()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user