zds-pickers 4.3.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,7 +29,17 @@ const midiToNoteName = (midiNumber) => {
29
29
  : midiNumber;
30
30
  return Note.fromMidi(num);
31
31
  };
32
- const createBundledPlayer = (soundfontData) => {
32
+ /**
33
+ * Key-up release, in seconds — the same 0.3 s soundfont-player's own envelope
34
+ * uses on the remote path. Pausing the element outright cut the sample
35
+ * mid-waveform, which clicked on every key-up and made a glissando sound
36
+ * like a row of switches.
37
+ */
38
+ const RELEASE_SECONDS = 0.3;
39
+ // setTargetAtTime is exponential, so it needs a time constant rather than a
40
+ // duration: five of them leave <1% of the level by the time the element stops.
41
+ const RELEASE_TIME_CONSTANT = RELEASE_SECONDS / 5;
42
+ const createBundledPlayer = (audioContext, soundfontData) => {
33
43
  const player = {
34
44
  play: (note, velocity) => {
35
45
  const noteName = midiToNoteName(note);
@@ -37,13 +47,29 @@ const createBundledPlayer = (soundfontData) => {
37
47
  if (!noteData) {
38
48
  return { stop: () => { } };
39
49
  }
50
+ // Still streamed by an <audio> element — no decode, so no first-note
51
+ // latency — but routed through a gain node so the release can ramp.
40
52
  const audio = new Audio(noteData);
41
- audio.volume = (velocity ?? 127) / 127;
53
+ const source = audioContext.createMediaElementSource(audio);
54
+ const gain = audioContext.createGain();
55
+ gain.gain.value = (velocity ?? 127) / 127;
56
+ source.connect(gain).connect(audioContext.destination);
42
57
  audio.play();
58
+ let stopped = false;
43
59
  return {
44
60
  stop: () => {
45
- audio.pause();
46
- audio.currentTime = 0;
61
+ if (stopped)
62
+ return;
63
+ stopped = true;
64
+ const now = audioContext.currentTime;
65
+ gain.gain.cancelScheduledValues(now);
66
+ gain.gain.setValueAtTime(gain.gain.value, now);
67
+ gain.gain.setTargetAtTime(0, now, RELEASE_TIME_CONSTANT);
68
+ window.setTimeout(() => {
69
+ audio.pause();
70
+ source.disconnect();
71
+ gain.disconnect();
72
+ }, RELEASE_SECONDS * 1000);
47
73
  },
48
74
  };
49
75
  },
@@ -54,7 +80,7 @@ const createBundledPlayer = (soundfontData) => {
54
80
  const loadBundledOrRemoteInstrument = (audioContext, instrumentName, format, soundfont, hostname) => {
55
81
  const soundfontData = window.MIDI?.Soundfont?.[instrumentName];
56
82
  if (soundfontData) {
57
- return Promise.resolve(createBundledPlayer(soundfontData));
83
+ return Promise.resolve(createBundledPlayer(audioContext, soundfontData));
58
84
  }
59
85
  // biome-ignore lint/suspicious/noConsole: intentional diagnostics for missing bundled soundfont
60
86
  console.warn(`[zds-pickers] Bundled soundfont "${instrumentName}" not found; falling back to remote load.`);
@@ -72,7 +98,9 @@ const loadBundledOrRemoteInstrument = (audioContext, instrumentName, format, sou
72
98
  };
73
99
  const SoundfontProvider = (props) => {
74
100
  const { audioContext, format, hostname, instrumentName, onChange, render, soundfont, } = props;
75
- const [isLoading, setIsLoading] = useState(true);
101
+ // No instrument means silent, not loading — starting true would leave a
102
+ // muted keyboard disabled forever, since nothing ever loads to clear it.
103
+ const [isLoading, setIsLoading] = useState(Boolean(instrumentName));
76
104
  const [activeAudioNodes, setActiveAudioNodes] = useState({});
77
105
  const [instrument, setInstrument] = useState(null);
78
106
  const loadInstrument = useCallback((nm) => {
@@ -111,7 +139,11 @@ const SoundfontProvider = (props) => {
111
139
  useEffect(() => {
112
140
  if (instrumentName) {
113
141
  loadInstrument(instrumentName);
142
+ return;
114
143
  }
144
+ // Unset after a load (muting): drop the loaded player so playNote no-ops.
145
+ setInstrument(null);
146
+ setIsLoading(false);
115
147
  }, [instrumentName, loadInstrument]);
116
148
  useEffect(() => {
117
149
  if (isDefined(instrumentName)) {
@@ -24,7 +24,7 @@ const makeNoteNameLabelRenderer = (noteLabels) => ({ isAccidental, isActive, mid
24
24
  }), children: pitchName }));
25
25
  };
26
26
  const KeyPicker = (props) => {
27
- const { value, onChange, disabled = false, height = 100, noteLabels, showNoteNames = false, width = 300, octave = 4, ...rest } = props;
27
+ const { value, onChange, disabled = false, height = 100, muted = false, noteLabels, showNoteNames = false, width = 300, octave = 4, ...rest } = props;
28
28
  const renderNoteLabel = useMemo(() => makeNoteNameLabelRenderer(noteLabels), [noteLabels]);
29
29
  const handleKeyClick = useCallback((note) => {
30
30
  if (disabled)
@@ -36,6 +36,6 @@ const KeyPicker = (props) => {
36
36
  const octaveStart = 60 + (octave - 4) * 12;
37
37
  const octaveEnd = octaveStart + 11;
38
38
  const shouldHighlight = value !== noSelection && value >= octaveStart && value <= octaveEnd;
39
- return (_jsx(OctavePlayer, { ...rest, className: showNoteNames ? 'ReactPiano--showNoteNames' : undefined, selectedNotes: shouldHighlight ? [value] : [], disabled: disabled, height: height, renderNoteLabel: showNoteNames ? renderNoteLabel : undefined, width: width, octave: octave, onClick: handleKeyClick, instrumentName: "acoustic_grand_piano" }));
39
+ return (_jsx(OctavePlayer, { ...rest, className: showNoteNames ? 'ReactPiano--showNoteNames' : undefined, selectedNotes: shouldHighlight ? [value] : [], disabled: disabled, height: height, renderNoteLabel: showNoteNames ? renderNoteLabel : undefined, width: width, octave: octave, onClick: handleKeyClick, instrumentName: muted ? undefined : 'acoustic_grand_piano' }));
40
40
  };
41
41
  export { KeyPicker };
@@ -4,6 +4,8 @@ type KeyPickerProps = Omit<PianoProviderProps, 'className' | 'instrumentName' |
4
4
  onChange: (value: number) => void;
5
5
  disabled?: boolean;
6
6
  height?: number;
7
+ /** Silences the piano sample a key click otherwise plays. */
8
+ muted?: boolean;
7
9
  /**
8
10
  * Chroma-indexed note names (index 0 = C) overriding the default flat
9
11
  * spelling — lets a caller label keys for a specific key/scale, where both
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zds-pickers",
3
- "version": "4.3.0",
3
+ "version": "4.5.0",
4
4
  "main": "./dist/index.cjs.js",
5
5
  "module": "./dist/index.es.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -55,9 +55,10 @@
55
55
  "react-dom": "18.3.1",
56
56
  "react-select": "5.10.1",
57
57
  "storybook": "10.5.5",
58
+ "tonal": "6.4.3",
58
59
  "typescript": "5.8.2",
59
60
  "vite": "^8.0.16",
60
- "vitest": "4.1.10",
61
+ "vitest": "4.1.11",
61
62
  "zds-mappings": "1.4.9"
62
63
  },
63
64
  "dependencies": {
@@ -66,7 +67,6 @@
66
67
  "d3-selection": "3.0.0",
67
68
  "react-svgmt": "2.0.2",
68
69
  "soundfont-player": "0.12.0",
69
- "tonal": "6.4.2",
70
70
  "zds-react-piano": "4.1.0"
71
71
  },
72
72
  "peerDependencies": {
@@ -74,6 +74,7 @@
74
74
  "react": ">=18.0.0",
75
75
  "react-dom": ">=18.0.0",
76
76
  "react-select": ">=5.10.0",
77
+ "tonal": ">=6.4.2",
77
78
  "zds-mappings": "^1.4.9"
78
79
  },
79
80
  "scripts": {