CC-Tweaked/src/main/java/dan200/computercraft/shared/peripheral/speaker/SpeakerPeripheral.java

413 lines
15 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* This file is part of ComputerCraft - http://www.computercraft.info
* Copyright Daniel Ratcliffe, 2011-2022. Do not distribute without permission.
* Send enquiries to dratcliffe@gmail.com
*/
package dan200.computercraft.shared.peripheral.speaker;
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 dan200.computercraft.shared.util.PauseAwareTimer;
import net.minecraft.network.play.server.SPlaySoundPacket;
import net.minecraft.server.MinecraftServer;
import net.minecraft.state.properties.NoteBlockInstrument;
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.vector.Vector3d;
import net.minecraft.world.World;
import javax.annotation.Nonnull;
import java.util.*;
import static dan200.computercraft.api.lua.LuaValues.checkFinite;
/**
* 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.
*
* ## Recipe
* <div class="recipe-container">
* <mc-recipe recipe="computercraft:speaker"></mc-recipe>
* </div>
*
* @cc.module speaker
* @cc.since 1.80pr1
*/
public abstract class SpeakerPeripheral implements IPeripheral
{
/**
* 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 = new HashSet<>();
private long clock = 0;
private long lastPositionTime;
private SpeakerPosition 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++;
SpeakerPosition position = getPosition();
World level = position.level();
Vector3d pos = position.position();
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 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 = PauseAwareTimer.getTime();
if( sound != null )
{
lastPlayTime = clock;
NetworkHandler.sendToAllAround(
new SpeakerPlayClientMessage( getSource(), position, sound.location, sound.volume, sound.pitch ),
level, pos, sound.volume * 16
);
syncedPosition( position );
}
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(), position, dfpwmState.getVolume(), dfpwmState.pullPending( now ) ),
level.getChunkAt( new BlockPos( pos ) )
);
syncedPosition( position );
// And notify computers that we have space for more audio.
synchronized( computers )
{
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( lastPosition != null && (clock - lastPositionTime) >= 20 && !lastPosition.withinDistance( position, 0.1 ) )
{
// TODO: What to do when entities move away? How do we notify people left behind that they're gone.
NetworkHandler.sendToAllTracking(
new SpeakerMoveClientMessage( getSource(), position ),
level.getChunkAt( new BlockPos( pos ) )
);
syncedPosition( position );
}
}
@Nonnull
public abstract SpeakerPosition getPosition();
@Nonnull
public UUID getSource()
{
return source;
}
public boolean madeSound()
{
DfpwmState state = dfpwmState;
return clock - lastPlayTime <= 20 || (state != null && state.isPlaying());
}
@Nonnull
@Override
public String getType()
{
return "speaker";
}
/**
* Plays a note block note 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 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
{
float volume = (float) checkFinite( 1, volumeA.orElse( 1.0 ) );
float pitch = (float) checkFinite( 2, pitchA.orElse( 1.0 ) );
ResourceLocation identifier;
try
{
identifier = new ResourceLocation( name );
}
catch( ResourceLocationException e )
{
throw new LuaException( "Malformed sound name '" + name + "' " );
}
synchronized( lock )
{
if( dfpwmState != null && dfpwmState.isPlaying() ) return false;
dfpwmState = null;
pendingSound = new PendingSound( identifier, volume, pitch );
return true;
}
}
/**
* Attempt to stream some audio data to the speaker.
*
* 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.
*
* :::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.
* :::
*
* {@literal @}{speaker_audio} provides a more complete guide 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 > 128 * 1024 ) 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 void stop()
{
shouldStop = true;
}
private void syncedPosition( SpeakerPosition position )
{
lastPosition = position;
lastPositionTime = clock;
}
@Override
public void attach( @Nonnull IComputerAccess computer )
{
synchronized( computers )
{
computers.add( computer );
}
}
@Override
public void detach( @Nonnull IComputerAccess computer )
{
synchronized( computers )
{
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 )
{
this.location = location;
this.volume = volume;
this.pitch = pitch;
}
}
}