Skip to content

Commit b37f43f

Browse files
authored
Feat: Safari compatiblity (#2)
* feat: hide midi for safari * feat: ConnectMidiClient * chore: rename files * feat: add findFirstSupportedFormat * feat: choose format automatically based on browser support * chore: remove format option * fix: safari selector styles * fix: soundfont sample stopid * chore: bump version
1 parent b64f4f1 commit b37f43f

21 files changed

+194
-153
lines changed

.changeset/pretty-bats-jam.md

-5
This file was deleted.

CHANGELOG.md

+7
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# smplr
22

3+
## 0.4.0
4+
5+
### Minor Changes
6+
7+
- b64f4f1: Add DrumMachine instrument
8+
- format selected based on browser compatibility
9+
310
## 0.3.0
411

512
Full rewrite. Samples stored at https://github.com/danigb/samples

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "smplr",
3-
"version": "0.3.0",
3+
"version": "0.4.0",
44
"homepage": "https://github.com/danigb/smplr#readme",
55
"description": "A Sampled collection of instruments",
66
"main": "dist/index.js",

site/pages/index.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default function Home() {
1919
<main className={"max-w-4xl mx-auto my-20 p-4" + inter.className}>
2020
<div className="flex items-end mb-16">
2121
<h1 className="text-6xl font-bold">smplr</h1>
22-
<div>0.3.0</div>
22+
<div>0.4.0</div>
2323
</div>
2424

2525
<div className="flex flex-col gap-8">

site/src/ConnectMidi.tsx

+13-82
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,18 @@
1-
import { useEffect, useRef, useState } from "react";
2-
import { Listener, WebMidi } from "webmidi";
1+
import dynamic from "next/dynamic";
2+
import { MidiInstrument } from "./ConnectMidiClient";
33

4-
type Instrument = {
5-
start(note: { note: number; velocity: number }): void;
6-
stop(note: { stopId: number }): void;
7-
};
4+
const Midi = dynamic(
5+
() => import("./ConnectMidiClient").then((module) => module.ConnectMidi),
6+
{
7+
ssr: false,
8+
}
9+
);
810

9-
export function ConnectMidi({
10-
instrument,
11-
}: {
12-
instrument: Instrument | undefined;
11+
export function ConnectMidi(props: {
12+
shouldRenderComponent?: boolean;
13+
instrument: MidiInstrument | undefined;
1314
}) {
14-
const inst = useRef<Instrument | null>(null);
15-
const [midiDeviceNames, setMidiDeviceNames] = useState<string[]>([]);
16-
const [midiDeviceName, setMidiDeviceName] = useState("");
17-
const [disconnectMidiDevice, setDisconnectMidiDevice] = useState<
18-
Listener[] | undefined
19-
>();
20-
const [lastNote, setLastNote] = useState("");
15+
const { shouldRenderComponent } = props;
2116

22-
useEffect(() => {
23-
WebMidi.enable().then(() => {
24-
const deviceNames = WebMidi.inputs.map((device) => device.name);
25-
setMidiDeviceNames(deviceNames);
26-
setMidiDeviceName(deviceNames[0]);
27-
});
28-
}, []);
29-
30-
inst.current = instrument ?? null;
31-
32-
return (
33-
<>
34-
<button
35-
className={
36-
"px-1 rounded " +
37-
(disconnectMidiDevice ? "bg-emerald-600" : "bg-zinc-700")
38-
}
39-
onClick={() => {
40-
if (disconnectMidiDevice) {
41-
setDisconnectMidiDevice(undefined);
42-
disconnectMidiDevice.forEach((listener) => listener.remove());
43-
return;
44-
}
45-
const device = WebMidi.inputs.find(
46-
(device) => device.name === midiDeviceName
47-
);
48-
if (!device) return;
49-
const listener = device.addListener("noteon", (event) => {
50-
const noteOn = {
51-
note: event.note.number,
52-
velocity: (event as any).rawVelocity,
53-
};
54-
inst.current?.start(noteOn);
55-
setLastNote(`${noteOn.note} (${noteOn.velocity})`);
56-
});
57-
const listenerOff = device.addListener("noteoff", (event) => {
58-
inst.current?.stop({ stopId: event.note.number });
59-
setLastNote("");
60-
});
61-
62-
setDisconnectMidiDevice([
63-
...(Array.isArray(listener) ? listener : [listener]),
64-
...(Array.isArray(listenerOff) ? listenerOff : [listenerOff]),
65-
]);
66-
}}
67-
>
68-
MIDI
69-
</button>
70-
<select
71-
className="bg-zinc-700 rounded py-[2px]"
72-
value={midiDeviceName}
73-
onChange={(e) => {
74-
const name = e.target.value;
75-
setMidiDeviceName(name);
76-
}}
77-
>
78-
{midiDeviceNames.map((name) => (
79-
<option key={name} value={name}>
80-
{name}
81-
</option>
82-
))}
83-
</select>
84-
<div className="opacity-50">{lastNote}</div>
85-
</>
86-
);
17+
return shouldRenderComponent ? <Midi {...props} /> : null;
8718
}

site/src/ConnectMidiClient.tsx

+100
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"use client";
2+
3+
import { useEffect, useRef, useState } from "react";
4+
import { Listener, WebMidi } from "webmidi";
5+
6+
function supportsMidi() {
7+
return (
8+
typeof window !== "undefined" &&
9+
typeof window.navigator !== "undefined" &&
10+
navigator.requestMIDIAccess !== undefined
11+
);
12+
}
13+
14+
export type MidiInstrument = {
15+
start(note: { note: number; velocity: number }): void;
16+
stop(note: { stopId: number }): void;
17+
};
18+
19+
export function ConnectMidi({
20+
instrument,
21+
}: {
22+
instrument: MidiInstrument | undefined;
23+
}) {
24+
const inst = useRef<MidiInstrument | null>(null);
25+
const [midiDeviceNames, setMidiDeviceNames] = useState<string[]>([]);
26+
const [midiDeviceName, setMidiDeviceName] = useState("");
27+
const [disconnectMidiDevice, setDisconnectMidiDevice] = useState<
28+
Listener[] | undefined
29+
>();
30+
const [lastNote, setLastNote] = useState("");
31+
32+
useEffect(() => {
33+
if (!supportsMidi()) return;
34+
WebMidi.enable().then(() => {
35+
const deviceNames = WebMidi.inputs.map((device) => device.name);
36+
setMidiDeviceNames(deviceNames);
37+
setMidiDeviceName(deviceNames[0]);
38+
});
39+
}, []);
40+
41+
inst.current = instrument ?? null;
42+
43+
if (!supportsMidi()) return null;
44+
45+
return (
46+
<>
47+
<button
48+
className={
49+
"px-1 rounded " +
50+
(disconnectMidiDevice ? "bg-emerald-600" : "bg-zinc-700")
51+
}
52+
onClick={() => {
53+
if (disconnectMidiDevice) {
54+
setDisconnectMidiDevice(undefined);
55+
disconnectMidiDevice.forEach((listener) => listener.remove());
56+
return;
57+
}
58+
const device = WebMidi.inputs.find(
59+
(device) => device.name === midiDeviceName
60+
);
61+
if (!device) return;
62+
const listener = device.addListener("noteon", (event) => {
63+
const noteOn = {
64+
note: event.note.number,
65+
velocity: (event as any).rawVelocity,
66+
};
67+
inst.current?.start(noteOn);
68+
setLastNote(`${noteOn.note} (${noteOn.velocity})`);
69+
});
70+
const listenerOff = device.addListener("noteoff", (event) => {
71+
inst.current?.stop({ stopId: event.note.number });
72+
setLastNote("");
73+
});
74+
75+
setDisconnectMidiDevice([
76+
...(Array.isArray(listener) ? listener : [listener]),
77+
...(Array.isArray(listenerOff) ? listenerOff : [listenerOff]),
78+
]);
79+
}}
80+
>
81+
MIDI
82+
</button>
83+
<select
84+
className="appearance-none bg-zinc-700 text-zinc-200 rounded border border-gray-400 py-2 px-3 leading-tight focus:outline-none focus:border-blue-500 py-[2px]"
85+
value={midiDeviceName}
86+
onChange={(e) => {
87+
const name = e.target.value;
88+
setMidiDeviceName(name);
89+
}}
90+
>
91+
{midiDeviceNames.map((name) => (
92+
<option key={name} value={name}>
93+
{name}
94+
</option>
95+
))}
96+
</select>
97+
<div className="opacity-50">{lastNote}</div>
98+
</>
99+
);
100+
}

site/src/DrumMachineExample.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ export function DrumMachineExample({ className }: { className?: string }) {
4747
>
4848
<div className="flex gap-4 mb-2">
4949
<select
50-
className="bg-zinc-700 rounded"
50+
className="appearance-none bg-zinc-700 text-zinc-200 rounded border border-gray-400 py-2 px-3 leading-tight focus:outline-none focus:border-blue-500"
5151
value={dmName}
5252
onChange={(e) => {
5353
const newName = e.target.value;

site/src/ElectricPianoExample.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export function ElectricPianoExample({ className }: { className?: string }) {
4545
<div className={status !== "ready" ? "opacity-30" : ""}>
4646
<div className="flex gap-4 mb-2 no-select">
4747
<select
48-
className="bg-zinc-700 rounded"
48+
className="appearance-none bg-zinc-700 text-zinc-200 rounded border border-gray-400 py-2 px-3 leading-tight focus:outline-none focus:border-blue-500"
4949
value={instrumentName}
5050
onChange={(e) => {
5151
const instrumentName = e.target.value;

site/src/MalletExample.tsx

+4-2
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ let instrumentNames = getMalletNames();
1212

1313
export function MalletExample({ className }: { className?: string }) {
1414
const [instrument, setInstrument] = useState<Mallet | undefined>(undefined);
15-
const [instrumentName, setInstrumentName] = useState(instrumentNames[0]);
15+
const [instrumentName, setInstrumentName] = useState<string>(
16+
instrumentNames[0]
17+
);
1618
const [status, setStatus] = useStatus();
1719
const [reverbMix, setReverbMix] = useState(0);
1820
const [volume, setVolume] = useState(100);
@@ -48,7 +50,7 @@ export function MalletExample({ className }: { className?: string }) {
4850
<div className={status !== "ready" ? "opacity-30" : ""}>
4951
<div className="flex gap-4 mb-2 no-select">
5052
<select
51-
className="bg-zinc-700 rounded"
53+
className="appearance-none bg-zinc-700 text-zinc-200 rounded border border-gray-400 py-2 px-3 leading-tight focus:outline-none focus:border-blue-500"
5254
value={instrumentName}
5355
onChange={(e) => {
5456
const instrumentName = e.target.value;

site/src/SoundfontExample.tsx

+3-3
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ export function SoundfontExample({ className }: { className?: string }) {
5858
>
5959
<div className="flex gap-4 mb-2">
6060
<select
61-
className="bg-zinc-700 rounded"
61+
className="appearance-none bg-zinc-700 text-zinc-200 rounded border border-gray-400 py-2 px-3 leading-tight focus:outline-none focus:border-blue-500"
6262
value={libraryName}
6363
onChange={(e) => {
6464
const libraryName = e.target.value;
@@ -73,7 +73,7 @@ export function SoundfontExample({ className }: { className?: string }) {
7373
))}
7474
</select>
7575
<select
76-
className="bg-zinc-700 rounded"
76+
className="appearance-none bg-zinc-700 text-zinc-200 rounded border border-gray-400 py-2 px-3 leading-tight focus:outline-none focus:border-blue-500"
7777
value={instrumentName}
7878
onChange={(e) => {
7979
const instrumentName = e.target.value;
@@ -132,7 +132,7 @@ export function SoundfontExample({ className }: { className?: string }) {
132132
instrument.start(note);
133133
}}
134134
onRelease={(midi) => {
135-
instrument?.stop({ stopId: "" + midi });
135+
instrument?.stop({ stopId: midi });
136136
}}
137137
/>
138138
</div>

src/drum-machine/drum-machine.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { loadAudioBuffer } from "../sampler/audio-buffers";
1+
import {
2+
findFirstSupportedFormat,
3+
loadAudioBuffer,
4+
} from "../sampler/load-audio";
25
import { Sampler, SamplerAudioLoader } from "../sampler/sampler";
36
import {
47
DrumMachineInstrument,
@@ -21,7 +24,6 @@ const INSTRUMENTS: Record<string, string> = {
2124
};
2225

2326
export type DrumMachineConfig = {
24-
format: "ogg" | "m4a";
2527
instrument: string;
2628
destination: AudioNode;
2729

@@ -44,14 +46,13 @@ export class DrumMachine extends Sampler {
4446

4547
super(context, {
4648
...options,
47-
buffers: drumMachineLoader(instrument, options.format ?? "ogg"),
49+
buffers: drumMachineLoader(instrument),
4850
noteToSample: (note, buffers, config) => {
4951
const sample = this.#instrument.nameToSample[note.note];
5052
return [sample ?? "", 0];
5153
},
5254
});
5355
instrument.then((instrument) => {
54-
console.log({ instrument });
5556
this.#instrument = instrument;
5657
});
5758
}
@@ -66,9 +67,9 @@ export class DrumMachine extends Sampler {
6667
}
6768

6869
function drumMachineLoader(
69-
instrument: Promise<DrumMachineInstrument>,
70-
format: string
70+
instrument: Promise<DrumMachineInstrument>
7171
): SamplerAudioLoader {
72+
const format = findFirstSupportedFormat(["ogg", "m4a"]) ?? "ogg";
7273
return async (context, buffers) => {
7374
const dm = await instrument;
7475
await Promise.all(

src/electric-piano.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { midiVelToGain } from "./sampler/note";
1+
import { midiVelToGain } from "./sampler/midi";
22
import { createControl } from "./sampler/signals";
33
import { SfzSampler, SfzSamplerConfig } from "./sfz/sfz-sampler";
44
import { createTremolo } from "./tremolo";

src/sampler/channel.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AudioInsert, connectSerial } from "./connect";
2-
import { midiVelToGain } from "./note";
2+
import { midiVelToGain } from "./midi";
33
import { createControl } from "./signals";
44

55
type ChannelOptions = {

src/sampler/audio-buffers.ts renamed to src/sampler/load-audio.ts

+18-10
Original file line numberDiff line numberDiff line change
@@ -23,15 +23,23 @@ export async function loadAudioBuffer(
2323
}
2424
}
2525

26-
export function findNearestMidi(
27-
midi: number,
28-
buffers: AudioBuffers
29-
): [number, number] {
30-
let i = 0;
31-
while (buffers[midi + i] === undefined && i < 128) {
32-
if (i > 0) i = -i;
33-
else i = -i + 1;
34-
}
26+
export function findFirstSupportedFormat(formats: string[]): string | null {
27+
if (typeof document === "undefined") return null;
3528

36-
return i === 127 ? [midi, 0] : [midi + i, -i * 100];
29+
const audio = document.createElement("audio");
30+
for (let i = 0; i < formats.length; i++) {
31+
const format = formats[i];
32+
const canPlay = audio.canPlayType(`audio/${format}`);
33+
if (canPlay === "probably" || canPlay === "maybe") {
34+
return format;
35+
}
36+
// check Safari for aac format
37+
if (format === "m4a") {
38+
const canPlay = audio.canPlayType(`audio/aac`);
39+
if (canPlay === "probably" || canPlay === "maybe") {
40+
return format;
41+
}
42+
}
43+
}
44+
return null;
3745
}

0 commit comments

Comments
 (0)