Add piano release/brightness controls and instrument hotkeys
This commit is contained in:
@@ -87,7 +87,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"keys": "Piano mode",
|
"keys": "Piano mode",
|
||||||
"description": "When using a piano: ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Enter/Escape exits"
|
"description": "When using a piano: 1-9 changes instrument, ASDFGHJKL;' plays C major notes, WETYUOP] plays sharps, Enter/Escape exits"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
||||||
window.CHGRID_WEB_VERSION = "2026.02.22 R201";
|
window.CHGRID_WEB_VERSION = "2026.02.22 R202";
|
||||||
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -128,16 +128,19 @@ const PRESETS: Record<Exclude<PianoInstrumentId, 'drum_kit'>, InstrumentPreset>
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ENVELOPE_BY_INSTRUMENT: Record<PianoInstrumentId, { attack: number; decay: number }> = {
|
export const DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT: Record<
|
||||||
piano: { attack: 15, decay: 45 },
|
PianoInstrumentId,
|
||||||
electric_piano: { attack: 12, decay: 40 },
|
{ attack: number; decay: number; release: number; brightness: number }
|
||||||
guitar: { attack: 8, decay: 35 },
|
> = {
|
||||||
organ: { attack: 25, decay: 70 },
|
piano: { attack: 15, decay: 45, release: 35, brightness: 55 },
|
||||||
bass: { attack: 10, decay: 35 },
|
electric_piano: { attack: 12, decay: 40, release: 30, brightness: 62 },
|
||||||
violin: { attack: 22, decay: 75 },
|
guitar: { attack: 8, decay: 35, release: 25, brightness: 50 },
|
||||||
synth_lead: { attack: 6, decay: 30 },
|
organ: { attack: 25, decay: 70, release: 45, brightness: 48 },
|
||||||
nintendo: { attack: 2, decay: 28 },
|
bass: { attack: 10, decay: 35, release: 28, brightness: 38 },
|
||||||
drum_kit: { attack: 1, decay: 22 },
|
violin: { attack: 22, decay: 75, release: 55, brightness: 58 },
|
||||||
|
synth_lead: { attack: 6, decay: 30, release: 22, brightness: 72 },
|
||||||
|
nintendo: { attack: 2, decay: 28, release: 18, brightness: 85 },
|
||||||
|
drum_kit: { attack: 1, decay: 22, release: 12, brightness: 68 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Maps 0..100 control values to note attack seconds. */
|
/** Maps 0..100 control values to note attack seconds. */
|
||||||
@@ -152,6 +155,18 @@ function decayPercentToSeconds(value: number): number {
|
|||||||
return 0.05 + (clamped / 100) * 2.7;
|
return 0.05 + (clamped / 100) * 2.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Maps 0..100 control values to release tail seconds after note-off. */
|
||||||
|
function releasePercentToSeconds(value: number): number {
|
||||||
|
const clamped = Math.max(0, Math.min(100, value));
|
||||||
|
return 0.03 + (clamped / 100) * 3.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maps 0..100 control values to low-pass filter brightness multiplier. */
|
||||||
|
function brightnessPercentToMultiplier(value: number): number {
|
||||||
|
const clamped = Math.max(0, Math.min(100, value));
|
||||||
|
return 0.45 + (clamped / 100) * 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
/** Converts midi note number to frequency in hertz. */
|
/** Converts midi note number to frequency in hertz. */
|
||||||
function midiToFrequency(midi: number): number {
|
function midiToFrequency(midi: number): number {
|
||||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||||
@@ -188,12 +203,14 @@ export class PianoSynth {
|
|||||||
instrument: PianoInstrumentId,
|
instrument: PianoInstrumentId,
|
||||||
attackPercent: number,
|
attackPercent: number,
|
||||||
decayPercent: number,
|
decayPercent: number,
|
||||||
|
releasePercent: number,
|
||||||
|
brightnessPercent: number,
|
||||||
context: PianoContext,
|
context: PianoContext,
|
||||||
spatial: PianoSpatialSource,
|
spatial: PianoSpatialSource,
|
||||||
): void {
|
): void {
|
||||||
if (this.voices.has(keyId)) return;
|
if (this.voices.has(keyId)) return;
|
||||||
if (instrument === 'drum_kit') {
|
if (instrument === 'drum_kit') {
|
||||||
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent);
|
this.playDrumHit(keyId, midi, context, spatial, attackPercent, decayPercent, releasePercent, brightnessPercent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,7 +218,7 @@ export class PianoSynth {
|
|||||||
const now = context.audioCtx.currentTime;
|
const now = context.audioCtx.currentTime;
|
||||||
const attackSeconds = attackPercentToSeconds(attackPercent);
|
const attackSeconds = attackPercentToSeconds(attackPercent);
|
||||||
const decaySeconds = decayPercentToSeconds(decayPercent);
|
const decaySeconds = decayPercentToSeconds(decayPercent);
|
||||||
const releaseSeconds = Math.max(0.02, decaySeconds * (preset.releaseScale ?? 1));
|
const releaseSeconds = Math.max(0.02, releasePercentToSeconds(releasePercent) * (preset.releaseScale ?? 1));
|
||||||
|
|
||||||
const spatialMix = resolveSpatialMix({
|
const spatialMix = resolveSpatialMix({
|
||||||
dx: spatial.x,
|
dx: spatial.x,
|
||||||
@@ -222,7 +239,7 @@ export class PianoSynth {
|
|||||||
if (preset.filter) {
|
if (preset.filter) {
|
||||||
const filter = context.audioCtx.createBiquadFilter();
|
const filter = context.audioCtx.createBiquadFilter();
|
||||||
filter.type = preset.filter.type;
|
filter.type = preset.filter.type;
|
||||||
filter.frequency.setValueAtTime(preset.filter.frequency, now);
|
filter.frequency.setValueAtTime(preset.filter.frequency * brightnessPercentToMultiplier(brightnessPercent), now);
|
||||||
filter.Q.setValueAtTime(preset.filter.q ?? 0.7, now);
|
filter.Q.setValueAtTime(preset.filter.q ?? 0.7, now);
|
||||||
voiceGain.connect(filter);
|
voiceGain.connect(filter);
|
||||||
tailNode = filter;
|
tailNode = filter;
|
||||||
@@ -310,6 +327,8 @@ export class PianoSynth {
|
|||||||
spatial: PianoSpatialSource,
|
spatial: PianoSpatialSource,
|
||||||
attackPercent: number,
|
attackPercent: number,
|
||||||
decayPercent: number,
|
decayPercent: number,
|
||||||
|
releasePercent: number,
|
||||||
|
brightnessPercent: number,
|
||||||
): void {
|
): void {
|
||||||
const now = context.audioCtx.currentTime;
|
const now = context.audioCtx.currentTime;
|
||||||
const spatialMix = resolveSpatialMix({
|
const spatialMix = resolveSpatialMix({
|
||||||
@@ -322,7 +341,9 @@ export class PianoSynth {
|
|||||||
const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
|
const typeIndex = Math.abs((midi % DRUM_VARIANTS.length) + this.hashKey(keyId)) % DRUM_VARIANTS.length;
|
||||||
const variant = DRUM_VARIANTS[typeIndex];
|
const variant = DRUM_VARIANTS[typeIndex];
|
||||||
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5;
|
const decaySeconds = 0.03 + decayPercentToSeconds(decayPercent) * 0.5;
|
||||||
|
const releaseSeconds = 0.02 + releasePercentToSeconds(releasePercent) * 0.35;
|
||||||
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
|
const attackSeconds = Math.max(0.001, attackPercentToSeconds(attackPercent) * 0.18);
|
||||||
|
const brightnessMultiplier = brightnessPercentToMultiplier(brightnessPercent);
|
||||||
|
|
||||||
const gain = context.audioCtx.createGain();
|
const gain = context.audioCtx.createGain();
|
||||||
gain.gain.setValueAtTime(0.0001, now);
|
gain.gain.setValueAtTime(0.0001, now);
|
||||||
@@ -340,34 +361,34 @@ export class PianoSynth {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (variant === 'kick_808') {
|
if (variant === 'kick_808') {
|
||||||
this.playKick808(context, gain, now, decaySeconds);
|
this.playKick808(context, gain, now, decaySeconds + releaseSeconds * 0.35);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'tom_low') {
|
if (variant === 'tom_low') {
|
||||||
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95);
|
this.playTom(context, gain, now, 120, 68, decaySeconds * 0.95 + releaseSeconds * 0.2);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'tom_high') {
|
if (variant === 'tom_high') {
|
||||||
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8);
|
this.playTom(context, gain, now, 220, 125, decaySeconds * 0.8 + releaseSeconds * 0.16);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'hat_closed') {
|
if (variant === 'hat_closed') {
|
||||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500, false);
|
this.playNoiseDrum(context, gain, now, decaySeconds * 0.25, 'highpass', 6500 * brightnessMultiplier, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'hat_open') {
|
if (variant === 'hat_open') {
|
||||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8, 'highpass', 5200, false);
|
this.playNoiseDrum(context, gain, now, decaySeconds * 0.8 + releaseSeconds * 0.2, 'highpass', 5200 * brightnessMultiplier, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'noise_8bit') {
|
if (variant === 'noise_8bit') {
|
||||||
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700, true);
|
this.playNoiseDrum(context, gain, now, decaySeconds * 0.45, 'bandpass', 2700 * brightnessMultiplier, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (variant === 'clap') {
|
if (variant === 'clap') {
|
||||||
this.playClap(context, gain, now, decaySeconds);
|
this.playClap(context, gain, now, decaySeconds + releaseSeconds * 0.1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.playSnare(context, gain, now, decaySeconds);
|
this.playSnare(context, gain, now, decaySeconds + releaseSeconds * 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 808-like kick: deep sine sweep with long-ish tail. */
|
/** 808-like kick: deep sine sweep with long-ish tail. */
|
||||||
|
|||||||
@@ -319,6 +319,8 @@ export function createItemPropertyEditor(deps: EditorDeps): {
|
|||||||
propertyKey === 'emitRange' ||
|
propertyKey === 'emitRange' ||
|
||||||
propertyKey === 'attack' ||
|
propertyKey === 'attack' ||
|
||||||
propertyKey === 'decay' ||
|
propertyKey === 'decay' ||
|
||||||
|
propertyKey === 'release' ||
|
||||||
|
propertyKey === 'brightness' ||
|
||||||
propertyKey === 'sides' ||
|
propertyKey === 'sides' ||
|
||||||
propertyKey === 'number'
|
propertyKey === 'number'
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const DEFAULT_PIANO_INSTRUMENT_OPTIONS = [
|
|||||||
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
const DEFAULT_ITEM_TYPE_EDITABLE_PROPERTIES: Record<ItemType, string[]> = {
|
||||||
radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'],
|
radio_station: ['title', 'streamUrl', 'enabled', 'mediaVolume', 'mediaChannel', 'mediaEffect', 'mediaEffectValue', 'facing', 'emitRange'],
|
||||||
dice: ['title', 'sides', 'number'],
|
dice: ['title', 'sides', 'number'],
|
||||||
piano: ['title', 'instrument', 'attack', 'decay', 'emitRange'],
|
piano: ['title', 'instrument', 'attack', 'decay', 'release', 'brightness', 'emitRange'],
|
||||||
wheel: ['title', 'spaces'],
|
wheel: ['title', 'spaces'],
|
||||||
clock: ['title', 'timeZone', 'use24Hour'],
|
clock: ['title', 'timeZone', 'use24Hour'],
|
||||||
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'],
|
widget: ['title', 'enabled', 'directional', 'facing', 'emitRange', 'emitVolume', 'emitSoundSpeed', 'emitSoundTempo', 'emitEffect', 'emitEffectValue', 'useSound', 'emitSound'],
|
||||||
@@ -241,6 +241,8 @@ export function itemPropertyLabel(key: string): string {
|
|||||||
if (key === 'instrument') return 'instrument';
|
if (key === 'instrument') return 'instrument';
|
||||||
if (key === 'attack') return 'attack';
|
if (key === 'attack') return 'attack';
|
||||||
if (key === 'decay') return 'decay';
|
if (key === 'decay') return 'decay';
|
||||||
|
if (key === 'release') return 'release';
|
||||||
|
if (key === 'brightness') return 'brightness';
|
||||||
if (key === 'useSound') return 'use sound';
|
if (key === 'useSound') return 'use sound';
|
||||||
if (key === 'emitSound') return 'emit sound';
|
if (key === 'emitSound') return 'emit sound';
|
||||||
return key;
|
return key;
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ import {
|
|||||||
shouldProxyStreamUrl,
|
shouldProxyStreamUrl,
|
||||||
} from './audio/radioStationRuntime';
|
} from './audio/radioStationRuntime';
|
||||||
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
import { ItemEmitRuntime } from './audio/itemEmitRuntime';
|
||||||
import { DEFAULT_ENVELOPE_BY_INSTRUMENT, PianoSynth, type PianoInstrumentId } from './audio/pianoSynth';
|
import {
|
||||||
|
DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT,
|
||||||
|
PIANO_INSTRUMENT_OPTIONS,
|
||||||
|
PianoSynth,
|
||||||
|
type PianoInstrumentId,
|
||||||
|
} from './audio/pianoSynth';
|
||||||
import { normalizeDegrees } from './audio/spatial';
|
import { normalizeDegrees } from './audio/spatial';
|
||||||
import {
|
import {
|
||||||
applyPastedText,
|
applyPastedText,
|
||||||
@@ -789,7 +794,14 @@ function getItemSpatialConfig(item: WorldItem): { range: number; directional: bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves piano params with safe defaults for local play mode. */
|
/** Resolves piano params with safe defaults for local play mode. */
|
||||||
function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attack: number; decay: number; emitRange: number } {
|
function getPianoParams(item: WorldItem): {
|
||||||
|
instrument: PianoInstrumentId;
|
||||||
|
attack: number;
|
||||||
|
decay: number;
|
||||||
|
release: number;
|
||||||
|
brightness: number;
|
||||||
|
emitRange: number;
|
||||||
|
} {
|
||||||
const rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase();
|
const rawInstrument = String(item.params.instrument ?? 'piano').trim().toLowerCase();
|
||||||
const instrument: PianoInstrumentId =
|
const instrument: PianoInstrumentId =
|
||||||
rawInstrument === 'electric_piano' ||
|
rawInstrument === 'electric_piano' ||
|
||||||
@@ -804,11 +816,16 @@ function getPianoParams(item: WorldItem): { instrument: PianoInstrumentId; attac
|
|||||||
: 'piano';
|
: 'piano';
|
||||||
const rawAttack = Number(item.params.attack);
|
const rawAttack = Number(item.params.attack);
|
||||||
const rawDecay = Number(item.params.decay);
|
const rawDecay = Number(item.params.decay);
|
||||||
|
const rawRelease = Number(item.params.release);
|
||||||
|
const rawBrightness = Number(item.params.brightness);
|
||||||
const rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15);
|
const rawEmitRange = Number(item.params.emitRange ?? getItemTypeGlobalProperties(item.type).emitRange ?? 15);
|
||||||
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
||||||
return {
|
return {
|
||||||
instrument,
|
instrument,
|
||||||
attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : 15)),
|
attack: Math.max(0, Math.min(100, Number.isFinite(rawAttack) ? Math.round(rawAttack) : defaults.attack)),
|
||||||
decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : 45)),
|
decay: Math.max(0, Math.min(100, Number.isFinite(rawDecay) ? Math.round(rawDecay) : defaults.decay)),
|
||||||
|
release: Math.max(0, Math.min(100, Number.isFinite(rawRelease) ? Math.round(rawRelease) : defaults.release)),
|
||||||
|
brightness: Math.max(0, Math.min(100, Number.isFinite(rawBrightness) ? Math.round(rawBrightness) : defaults.brightness)),
|
||||||
emitRange: Math.max(5, Math.min(20, Number.isFinite(rawEmitRange) ? Math.round(rawEmitRange) : 15)),
|
emitRange: Math.max(5, Math.min(20, Number.isFinite(rawEmitRange) ? Math.round(rawEmitRange) : 15)),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -870,7 +887,10 @@ function stopPianoUseMode(announce = true): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */
|
/** Plays one short C4 preview using the piano item's current/overridden envelope+instrument. */
|
||||||
async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number }>): Promise<void> {
|
async function previewPianoSettingChange(
|
||||||
|
item: WorldItem,
|
||||||
|
overrides: Partial<{ instrument: PianoInstrumentId; attack: number; decay: number; release: number; brightness: number }>,
|
||||||
|
): Promise<void> {
|
||||||
if (item.type !== 'piano') return;
|
if (item.type !== 'piano') return;
|
||||||
await audio.ensureContext();
|
await audio.ensureContext();
|
||||||
const ctx = audio.context;
|
const ctx = audio.context;
|
||||||
@@ -880,6 +900,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i
|
|||||||
const instrument = overrides.instrument ?? current.instrument;
|
const instrument = overrides.instrument ?? current.instrument;
|
||||||
const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack)));
|
const attack = Math.max(0, Math.min(100, Math.round(overrides.attack ?? current.attack)));
|
||||||
const decay = Math.max(0, Math.min(100, Math.round(overrides.decay ?? current.decay)));
|
const decay = Math.max(0, Math.min(100, Math.round(overrides.decay ?? current.decay)));
|
||||||
|
const release = Math.max(0, Math.min(100, Math.round(overrides.release ?? current.release)));
|
||||||
|
const brightness = Math.max(0, Math.min(100, Math.round(overrides.brightness ?? current.brightness)));
|
||||||
const sourceX = item.carrierId === state.player.id ? state.player.x : item.x;
|
const sourceX = item.carrierId === state.player.id ? state.player.x : item.x;
|
||||||
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y;
|
const sourceY = item.carrierId === state.player.id ? state.player.y : item.y;
|
||||||
const previewKeyId = '__piano_preview_c4__';
|
const previewKeyId = '__piano_preview_c4__';
|
||||||
@@ -890,6 +912,8 @@ async function previewPianoSettingChange(item: WorldItem, overrides: Partial<{ i
|
|||||||
instrument,
|
instrument,
|
||||||
attack,
|
attack,
|
||||||
decay,
|
decay,
|
||||||
|
release,
|
||||||
|
brightness,
|
||||||
{ audioCtx: ctx, destination },
|
{ audioCtx: ctx, destination },
|
||||||
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange },
|
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: current.emitRange },
|
||||||
);
|
);
|
||||||
@@ -911,6 +935,8 @@ function playRemotePianoNote(note: {
|
|||||||
instrument: string;
|
instrument: string;
|
||||||
attack: number;
|
attack: number;
|
||||||
decay: number;
|
decay: number;
|
||||||
|
release: number;
|
||||||
|
brightness: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
emitRange: number;
|
emitRange: number;
|
||||||
@@ -927,6 +953,8 @@ function playRemotePianoNote(note: {
|
|||||||
normalizePianoInstrument(note.instrument),
|
normalizePianoInstrument(note.instrument),
|
||||||
Math.max(0, Math.min(100, Math.round(note.attack))),
|
Math.max(0, Math.min(100, Math.round(note.attack))),
|
||||||
Math.max(0, Math.min(100, Math.round(note.decay))),
|
Math.max(0, Math.min(100, Math.round(note.decay))),
|
||||||
|
Math.max(0, Math.min(100, Math.round(note.release))),
|
||||||
|
Math.max(0, Math.min(100, Math.round(note.brightness))),
|
||||||
{ audioCtx: ctx, destination },
|
{ audioCtx: ctx, destination },
|
||||||
{
|
{
|
||||||
x: note.x - state.player.x,
|
x: note.x - state.player.x,
|
||||||
@@ -1182,6 +1210,8 @@ function inferItemPropertyValueType(item: WorldItem, key: string): string | unde
|
|||||||
key === 'emitRange' ||
|
key === 'emitRange' ||
|
||||||
key === 'attack' ||
|
key === 'attack' ||
|
||||||
key === 'decay' ||
|
key === 'decay' ||
|
||||||
|
key === 'release' ||
|
||||||
|
key === 'brightness' ||
|
||||||
key === 'sides' ||
|
key === 'sides' ||
|
||||||
key === 'number' ||
|
key === 'number' ||
|
||||||
key === 'useCooldownMs'
|
key === 'useCooldownMs'
|
||||||
@@ -2253,6 +2283,37 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
stopPianoUseMode(false);
|
stopPianoUseMode(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (code.startsWith('Digit')) {
|
||||||
|
const digit = Number(code.slice(5));
|
||||||
|
if (Number.isInteger(digit) && digit >= 1 && digit <= 9) {
|
||||||
|
const instrument = PIANO_INSTRUMENT_OPTIONS[digit - 1];
|
||||||
|
if (instrument) {
|
||||||
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
||||||
|
item.params.instrument = instrument;
|
||||||
|
item.params.attack = defaults.attack;
|
||||||
|
item.params.decay = defaults.decay;
|
||||||
|
item.params.release = defaults.release;
|
||||||
|
item.params.brightness = defaults.brightness;
|
||||||
|
signaling.send({
|
||||||
|
type: 'item_update',
|
||||||
|
itemId,
|
||||||
|
params: {
|
||||||
|
instrument,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
void previewPianoSettingChange(item, {
|
||||||
|
instrument,
|
||||||
|
attack: defaults.attack,
|
||||||
|
decay: defaults.decay,
|
||||||
|
release: defaults.release,
|
||||||
|
brightness: defaults.brightness,
|
||||||
|
});
|
||||||
|
updateStatus(`Instrument ${instrument}.`);
|
||||||
|
audio.sfxUiBlip();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
const midi = getPianoMidiForCode(code);
|
const midi = getPianoMidiForCode(code);
|
||||||
if (midi === null) return;
|
if (midi === null) return;
|
||||||
if (activePianoKeys.has(code)) return;
|
if (activePianoKeys.has(code)) return;
|
||||||
@@ -2269,6 +2330,8 @@ function handlePianoUseModeInput(code: string): void {
|
|||||||
config.instrument,
|
config.instrument,
|
||||||
config.attack,
|
config.attack,
|
||||||
config.decay,
|
config.decay,
|
||||||
|
config.release,
|
||||||
|
config.brightness,
|
||||||
{ audioCtx: ctx, destination },
|
{ audioCtx: ctx, destination },
|
||||||
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange },
|
{ x: sourceX - state.player.x, y: sourceY - state.player.y, range: config.emitRange },
|
||||||
);
|
);
|
||||||
@@ -2528,8 +2591,14 @@ const itemPropertyEditor = createItemPropertyEditor({
|
|||||||
if (item.type !== 'piano') return;
|
if (item.type !== 'piano') return;
|
||||||
if (key === 'instrument') {
|
if (key === 'instrument') {
|
||||||
const instrument = normalizePianoInstrument(value);
|
const instrument = normalizePianoInstrument(value);
|
||||||
const defaults = DEFAULT_ENVELOPE_BY_INSTRUMENT[instrument];
|
const defaults = DEFAULT_PIANO_SETTINGS_BY_INSTRUMENT[instrument];
|
||||||
void previewPianoSettingChange(item, { instrument, attack: defaults.attack, decay: defaults.decay });
|
void previewPianoSettingChange(item, {
|
||||||
|
instrument,
|
||||||
|
attack: defaults.attack,
|
||||||
|
decay: defaults.decay,
|
||||||
|
release: defaults.release,
|
||||||
|
brightness: defaults.brightness,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key === 'attack') {
|
if (key === 'attack') {
|
||||||
@@ -2542,6 +2611,18 @@ const itemPropertyEditor = createItemPropertyEditor({
|
|||||||
const decay = Number(value);
|
const decay = Number(value);
|
||||||
if (!Number.isFinite(decay)) return;
|
if (!Number.isFinite(decay)) return;
|
||||||
void previewPianoSettingChange(item, { decay });
|
void previewPianoSettingChange(item, { decay });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'release') {
|
||||||
|
const release = Number(value);
|
||||||
|
if (!Number.isFinite(release)) return;
|
||||||
|
void previewPianoSettingChange(item, { release });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === 'brightness') {
|
||||||
|
const brightness = Number(value);
|
||||||
|
if (!Number.isFinite(brightness)) return;
|
||||||
|
void previewPianoSettingChange(item, { brightness });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateStatus,
|
updateStatus,
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ type MessageHandlerDeps = {
|
|||||||
instrument: string;
|
instrument: string;
|
||||||
attack: number;
|
attack: number;
|
||||||
decay: number;
|
decay: number;
|
||||||
|
release: number;
|
||||||
|
brightness: number;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
emitRange: number;
|
emitRange: number;
|
||||||
@@ -275,6 +277,8 @@ export function createOnMessageHandler(deps: MessageHandlerDeps): (message: Inco
|
|||||||
instrument: message.instrument,
|
instrument: message.instrument,
|
||||||
attack: message.attack,
|
attack: message.attack,
|
||||||
decay: message.decay,
|
decay: message.decay,
|
||||||
|
release: message.release,
|
||||||
|
brightness: message.brightness,
|
||||||
x: message.x,
|
x: message.x,
|
||||||
y: message.y,
|
y: message.y,
|
||||||
emitRange: message.emitRange,
|
emitRange: message.emitRange,
|
||||||
|
|||||||
@@ -160,6 +160,8 @@ export const itemPianoNoteSchema = z.object({
|
|||||||
instrument: z.string(),
|
instrument: z.string(),
|
||||||
attack: z.number().int().min(0).max(100),
|
attack: z.number().int().min(0).max(100),
|
||||||
decay: z.number().int().min(0).max(100),
|
decay: z.number().int().min(0).max(100),
|
||||||
|
release: z.number().int().min(0).max(100),
|
||||||
|
brightness: z.number().int().min(0).max(100),
|
||||||
x: z.number().int(),
|
x: z.number().int(),
|
||||||
y: z.number().int(),
|
y: z.number().int(),
|
||||||
emitRange: z.number().int().min(1),
|
emitRange: z.number().int().min(1),
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ Applies to effect select, user/item list modes, item selection, item property li
|
|||||||
|
|
||||||
## Piano Use Mode
|
## Piano Use Mode
|
||||||
|
|
||||||
|
- `1-9`: Switch instrument preset quickly
|
||||||
- `A S D F G H J K L ; '`: Play white keys (C major from C4 upward)
|
- `A S D F G H J K L ; '`: Play white keys (C major from C4 upward)
|
||||||
- `W E T Y U O P ]`: Play sharps
|
- `W E T Y U O P ]`: Play sharps
|
||||||
- Multiple keys can be held/played at once
|
- Multiple keys can be held/played at once
|
||||||
|
|||||||
@@ -165,6 +165,8 @@
|
|||||||
"instrument": "piano",
|
"instrument": "piano",
|
||||||
"attack": 15,
|
"attack": 15,
|
||||||
"decay": 45,
|
"decay": 45,
|
||||||
|
"release": 35,
|
||||||
|
"brightness": 55,
|
||||||
"emitRange": 15
|
"emitRange": 15
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -174,6 +176,8 @@
|
|||||||
- Selecting a new instrument resets `attack`/`decay` to that instrument's defaults.
|
- Selecting a new instrument resets `attack`/`decay` to that instrument's defaults.
|
||||||
- `attack`: integer, range `0-100`, default `15`.
|
- `attack`: integer, range `0-100`, default `15`.
|
||||||
- `decay`: integer, range `0-100`, default `45`.
|
- `decay`: integer, range `0-100`, default `45`.
|
||||||
|
- `release`: integer, range `0-100`, default `35`.
|
||||||
|
- `brightness`: integer, range `0-100`, default `55`.
|
||||||
- `emitRange`: integer, range `5-20`, default `15`.
|
- `emitRange`: integer, range `5-20`, default `15`.
|
||||||
|
|
||||||
## Packet Shapes
|
## Packet Shapes
|
||||||
|
|||||||
@@ -159,6 +159,8 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `instrument="piano"`
|
- `instrument="piano"`
|
||||||
- `attack=15`
|
- `attack=15`
|
||||||
- `decay=45`
|
- `decay=45`
|
||||||
|
- `release=35`
|
||||||
|
- `brightness=55`
|
||||||
- `emitRange=15`
|
- `emitRange=15`
|
||||||
- Global:
|
- Global:
|
||||||
- `useSound=none`
|
- `useSound=none`
|
||||||
@@ -174,8 +176,10 @@ This is behavior-focused documentation for item types and their defaults.
|
|||||||
- `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit`
|
- `instrument`: `piano | electric_piano | guitar | organ | bass | violin | synth_lead | nintendo | drum_kit`
|
||||||
- `attack`: integer `0..100`
|
- `attack`: integer `0..100`
|
||||||
- `decay`: integer `0..100`
|
- `decay`: integer `0..100`
|
||||||
|
- `release`: integer `0..100`
|
||||||
|
- `brightness`: integer `0..100`
|
||||||
- `emitRange`: integer `5..20`
|
- `emitRange`: integer `5..20`
|
||||||
- Instrument changes reset `attack`/`decay` to instrument defaults.
|
- Instrument changes reset `attack`/`decay`/`release`/`brightness` to instrument defaults.
|
||||||
|
|
||||||
## Adding A New Item Type (Registry V1)
|
## Adding A New Item Type (Registry V1)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from ..models import WorldItem
|
|||||||
|
|
||||||
LABEL = "piano"
|
LABEL = "piano"
|
||||||
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
|
TOOLTIP = "Playable keyboard instrument with multiple synth voices."
|
||||||
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "emitRange")
|
EDITABLE_PROPERTIES: tuple[str, ...] = ("title", "instrument", "attack", "decay", "release", "brightness", "emitRange")
|
||||||
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
CAPABILITIES: tuple[str, ...] = ("editable", "carryable", "deletable", "usable")
|
||||||
USE_SOUND: str | None = None
|
USE_SOUND: str | None = None
|
||||||
EMIT_SOUND: str | None = None
|
EMIT_SOUND: str | None = None
|
||||||
@@ -21,6 +21,8 @@ DEFAULT_PARAMS: dict = {
|
|||||||
"instrument": "piano",
|
"instrument": "piano",
|
||||||
"attack": 15,
|
"attack": 15,
|
||||||
"decay": 45,
|
"decay": 45,
|
||||||
|
"release": 35,
|
||||||
|
"brightness": 55,
|
||||||
"emitRange": 15,
|
"emitRange": 15,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,16 +38,16 @@ INSTRUMENT_OPTIONS: tuple[str, ...] = (
|
|||||||
"drum_kit",
|
"drum_kit",
|
||||||
)
|
)
|
||||||
|
|
||||||
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int]] = {
|
DEFAULT_ENVELOPE_BY_INSTRUMENT: dict[str, tuple[int, int, int, int]] = {
|
||||||
"piano": (15, 45),
|
"piano": (15, 45, 35, 55),
|
||||||
"electric_piano": (12, 40),
|
"electric_piano": (12, 40, 30, 62),
|
||||||
"guitar": (8, 35),
|
"guitar": (8, 35, 25, 50),
|
||||||
"organ": (25, 70),
|
"organ": (25, 70, 45, 48),
|
||||||
"bass": (10, 35),
|
"bass": (10, 35, 28, 38),
|
||||||
"violin": (22, 75),
|
"violin": (22, 75, 55, 58),
|
||||||
"synth_lead": (6, 30),
|
"synth_lead": (6, 30, 22, 72),
|
||||||
"nintendo": (2, 28),
|
"nintendo": (2, 28, 18, 85),
|
||||||
"drum_kit": (1, 22),
|
"drum_kit": (1, 22, 12, 68),
|
||||||
}
|
}
|
||||||
|
|
||||||
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
||||||
@@ -61,6 +63,16 @@ PROPERTY_METADATA: dict[str, dict[str, object]] = {
|
|||||||
"tooltip": "How long notes ring out after the initial hit.",
|
"tooltip": "How long notes ring out after the initial hit.",
|
||||||
"range": {"min": 0, "max": 100, "step": 1},
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
},
|
},
|
||||||
|
"release": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "How long notes continue after key release.",
|
||||||
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
|
"brightness": {
|
||||||
|
"valueType": "number",
|
||||||
|
"tooltip": "Tone brightness; higher values sound brighter.",
|
||||||
|
"range": {"min": 0, "max": 100, "step": 1},
|
||||||
|
},
|
||||||
"emitRange": {
|
"emitRange": {
|
||||||
"valueType": "number",
|
"valueType": "number",
|
||||||
"tooltip": "Maximum distance in squares where this piano can be heard.",
|
"tooltip": "Maximum distance in squares where this piano can be heard.",
|
||||||
@@ -91,11 +103,27 @@ def validate_update(_item: WorldItem, next_params: dict) -> dict:
|
|||||||
if not (0 <= decay <= 100):
|
if not (0 <= decay <= 100):
|
||||||
raise ValueError("decay must be between 0 and 100.")
|
raise ValueError("decay must be between 0 and 100.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
release = int(next_params.get("release", 35))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("release must be an integer between 0 and 100.") from exc
|
||||||
|
if not (0 <= release <= 100):
|
||||||
|
raise ValueError("release must be between 0 and 100.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
brightness = int(next_params.get("brightness", 55))
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("brightness must be an integer between 0 and 100.") from exc
|
||||||
|
if not (0 <= brightness <= 100):
|
||||||
|
raise ValueError("brightness must be between 0 and 100.")
|
||||||
|
|
||||||
# When instrument changes, reset envelope to instrument-appropriate defaults.
|
# When instrument changes, reset envelope to instrument-appropriate defaults.
|
||||||
if instrument != previous_instrument:
|
if instrument != previous_instrument:
|
||||||
attack, decay = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(instrument, (15, 45))
|
attack, decay, release, brightness = DEFAULT_ENVELOPE_BY_INSTRUMENT.get(instrument, (15, 45, 35, 55))
|
||||||
next_params["attack"] = attack
|
next_params["attack"] = attack
|
||||||
next_params["decay"] = decay
|
next_params["decay"] = decay
|
||||||
|
next_params["release"] = release
|
||||||
|
next_params["brightness"] = brightness
|
||||||
|
|
||||||
try:
|
try:
|
||||||
emit_range = int(next_params.get("emitRange", 15))
|
emit_range = int(next_params.get("emitRange", 15))
|
||||||
|
|||||||
@@ -232,6 +232,8 @@ class ItemPianoNoteBroadcastPacket(BasePacket):
|
|||||||
instrument: str
|
instrument: str
|
||||||
attack: int
|
attack: int
|
||||||
decay: int
|
decay: int
|
||||||
|
release: int
|
||||||
|
brightness: int
|
||||||
x: int
|
x: int
|
||||||
y: int
|
y: int
|
||||||
emitRange: int
|
emitRange: int
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ from .models import (
|
|||||||
LOGGER = logging.getLogger("chgrid.server")
|
LOGGER = logging.getLogger("chgrid.server")
|
||||||
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
PACKET_LOGGER = logging.getLogger("chgrid.server.packet")
|
||||||
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
CLIENT_PACKET_ADAPTER = TypeAdapter(ClientPacket)
|
||||||
MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 32
|
MAX_ACTIVE_PIANO_KEYS_PER_CLIENT = 12
|
||||||
|
|
||||||
|
|
||||||
class SignalingServer:
|
class SignalingServer:
|
||||||
@@ -679,6 +679,8 @@ class SignalingServer:
|
|||||||
instrument = str(item.params.get("instrument", "piano")).strip().lower()
|
instrument = str(item.params.get("instrument", "piano")).strip().lower()
|
||||||
attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15
|
attack = int(item.params.get("attack", 15)) if isinstance(item.params.get("attack", 15), (int, float)) else 15
|
||||||
decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45
|
decay = int(item.params.get("decay", 45)) if isinstance(item.params.get("decay", 45), (int, float)) else 45
|
||||||
|
release = int(item.params.get("release", 35)) if isinstance(item.params.get("release", 35), (int, float)) else 35
|
||||||
|
brightness = int(item.params.get("brightness", 55)) if isinstance(item.params.get("brightness", 55), (int, float)) else 55
|
||||||
emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15
|
emit_range = int(item.params.get("emitRange", 15)) if isinstance(item.params.get("emitRange", 15), (int, float)) else 15
|
||||||
source_x = client.x if item.carrierId == client.id else item.x
|
source_x = client.x if item.carrierId == client.id else item.x
|
||||||
source_y = client.y if item.carrierId == client.id else item.y
|
source_y = client.y if item.carrierId == client.id else item.y
|
||||||
@@ -693,6 +695,8 @@ class SignalingServer:
|
|||||||
instrument=instrument,
|
instrument=instrument,
|
||||||
attack=max(0, min(100, attack)),
|
attack=max(0, min(100, attack)),
|
||||||
decay=max(0, min(100, decay)),
|
decay=max(0, min(100, decay)),
|
||||||
|
release=max(0, min(100, release)),
|
||||||
|
brightness=max(0, min(100, brightness)),
|
||||||
x=source_x,
|
x=source_x,
|
||||||
y=source_y,
|
y=source_y,
|
||||||
emitRange=max(5, min(20, emit_range)),
|
emitRange=max(5, min(20, emit_range)),
|
||||||
|
|||||||
@@ -379,6 +379,8 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert item.params.get("instrument") == "drum_kit"
|
assert item.params.get("instrument") == "drum_kit"
|
||||||
assert item.params.get("attack") == 1
|
assert item.params.get("attack") == 1
|
||||||
assert item.params.get("decay") == 22
|
assert item.params.get("decay") == 22
|
||||||
|
assert item.params.get("release") == 12
|
||||||
|
assert item.params.get("brightness") == 68
|
||||||
assert item.params.get("emitRange") == 12
|
assert item.params.get("emitRange") == 12
|
||||||
|
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
@@ -389,6 +391,8 @@ async def test_piano_update_and_use(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
assert item.params.get("instrument") == "nintendo"
|
assert item.params.get("instrument") == "nintendo"
|
||||||
assert item.params.get("attack") == 2
|
assert item.params.get("attack") == 2
|
||||||
assert item.params.get("decay") == 28
|
assert item.params.get("decay") == 28
|
||||||
|
assert item.params.get("release") == 18
|
||||||
|
assert item.params.get("brightness") == 85
|
||||||
|
|
||||||
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
|
await server._handle_message(client, json.dumps({"type": "item_use", "itemId": item.id}))
|
||||||
assert send_payloads[-1].ok is True
|
assert send_payloads[-1].ok is True
|
||||||
@@ -444,6 +448,8 @@ async def test_piano_note_packet_broadcasts(monkeypatch: pytest.MonkeyPatch) ->
|
|||||||
assert getattr(packet, "instrument", "") == "organ"
|
assert getattr(packet, "instrument", "") == "organ"
|
||||||
assert getattr(packet, "attack", -1) == 20
|
assert getattr(packet, "attack", -1) == 20
|
||||||
assert getattr(packet, "decay", -1) == 60
|
assert getattr(packet, "decay", -1) == 60
|
||||||
|
assert getattr(packet, "release", -1) == 35
|
||||||
|
assert getattr(packet, "brightness", -1) == 55
|
||||||
assert getattr(packet, "emitRange", -1) == 12
|
assert getattr(packet, "emitRange", -1) == 12
|
||||||
|
|
||||||
|
|
||||||
@@ -467,16 +473,16 @@ async def test_piano_note_key_cap(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(server, "_send", fake_send)
|
monkeypatch.setattr(server, "_send", fake_send)
|
||||||
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
monkeypatch.setattr(server, "_broadcast", fake_broadcast)
|
||||||
|
|
||||||
for index in range(32):
|
for index in range(12):
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
sender,
|
sender,
|
||||||
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": f"Key{index}", "midi": 60, "on": True}),
|
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": f"Key{index}", "midi": 60, "on": True}),
|
||||||
)
|
)
|
||||||
assert len(broadcast_payloads) == 32
|
assert len(broadcast_payloads) == 12
|
||||||
|
|
||||||
# 33rd distinct held key is dropped by cap.
|
# 13th distinct held key is dropped by cap.
|
||||||
await server._handle_message(
|
await server._handle_message(
|
||||||
sender,
|
sender,
|
||||||
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}),
|
json.dumps({"type": "item_piano_note", "itemId": item.id, "keyId": "KeyOverflow", "midi": 60, "on": True}),
|
||||||
)
|
)
|
||||||
assert len(broadcast_payloads) == 32
|
assert len(broadcast_payloads) == 12
|
||||||
|
|||||||
Reference in New Issue
Block a user