Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"use-l10n": "^0.0.4",
"use-sync-external-store": "^1.5.0",
"wav-encoder": "^1.3.0",
"web-ble-midi": "^0.1.0",
"zod": "^3.24.4"
},
"devDependencies": {
Expand All @@ -90,6 +91,7 @@
"@types/react-window": "^1.8.8",
"@types/use-sync-external-store": "^1.5.0",
"@types/wav-encoder": "^1.3.3",
"@types/web-bluetooth": "^0.0.21",
"@types/webmidi": "2.1.0",
"@types/webpack-env": "1.18.8",
"@types/wicg-file-system-access": "^2023.10.6",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Device, useMIDIDevice } from "../../../hooks/useMIDIDevice"
import { Localized } from "../../../localize/useLocalization"
import { DialogContent, DialogTitle } from "../../Dialog/Dialog"
import { Alert } from "../../ui/Alert"
import { Button } from "../../ui/Button"
import { Checkbox } from "../../ui/Checkbox"
import { CircularProgress } from "../../ui/CircularProgress"
import { Label } from "../../ui/Label"
Expand Down Expand Up @@ -60,6 +61,7 @@ export const MIDIDeviceView: FC = () => {
requestError,
setInputEnable,
setOutputEnable,
requestBluetoothMIDIDevice,
} = useMIDIDevice()

return (
Expand All @@ -80,6 +82,12 @@ export const MIDIDeviceView: FC = () => {
<SectionTitle>
<Localized name="inputs" />
</SectionTitle>
<Button
onClick={requestBluetoothMIDIDevice}
style={{ marginBottom: 8 }}
>
Connect Bluetooth MIDI Device
</Button>
<DeviceList>
{inputDevices.length === 0 && (
<Notice>
Expand All @@ -90,7 +98,9 @@ export const MIDIDeviceView: FC = () => {
<DeviceRow
key={device.id}
device={device}
onCheck={(checked) => setInputEnable(device.id, checked)}
onCheck={async (checked) => {
setInputEnable(device.id, checked)
}}
/>
))}
</DeviceList>
Expand Down
59 changes: 48 additions & 11 deletions app/src/hooks/useMIDIDevice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ export interface Device {
name: string
isConnected: boolean
isEnabled: boolean
isBluetooth?: boolean
}

export function useMIDIDevice() {
const { midiDeviceStore } = useStores()
const { midiDeviceStore, bluetoothMIDIDeviceStore } = useStores()

const inputs = useMobxStore(({ midiDeviceStore }) => midiDeviceStore.inputs)
const outputs = useMobxStore(({ midiDeviceStore }) => midiDeviceStore.outputs)
const btInputs = useMobxStore(
({ bluetoothMIDIDeviceStore }) => bluetoothMIDIDeviceStore.inputs,
)
const btEnabledInputs = useMobxStore(
({ bluetoothMIDIDeviceStore }) => bluetoothMIDIDeviceStore.enabledInputs,
)

const enabledInputs = useMobxStore(
({ midiDeviceStore }) => midiDeviceStore.enabledInputs,
Expand All @@ -26,12 +33,23 @@ export function useMIDIDevice() {
({ midiDeviceStore }) => midiDeviceStore.isFactorySoundEnabled,
)

const inputDevices: Device[] = inputs.map((device) => ({
id: device.id,
name: formatName(device),
isConnected: device.state === "connected",
isEnabled: enabledInputs[device.id],
}))
// 通常MIDI + Bluetooth MIDI
const inputDevices: Device[] = [
...inputs.map((device) => ({
id: device.id,
name: formatName(device),
isConnected: device.state === "connected",
isEnabled: enabledInputs[device.id],
isBluetooth: false,
})),
...btInputs.map((d) => ({
id: d.id,
name: d.name ?? "Bluetooth MIDI Device",
isConnected: btInputs.some((i) => i.id === d.id),
isEnabled: btEnabledInputs[d.id],
isBluetooth: true,
})),
]

const outputDevices: Device[] = [
{
Expand All @@ -43,26 +61,45 @@ export function useMIDIDevice() {
name: formatName(device),
isConnected: device.state === "connected",
isEnabled: enabledOutputs[device.id],
isBluetooth: false,
})),
]

return {
inputDevices,
outputDevices,
get isLoading() {
return useMobxStore(({ midiDeviceStore }) => midiDeviceStore.isLoading)
return (
useMobxStore(({ midiDeviceStore }) => midiDeviceStore.isLoading) ||
useMobxStore(
({ bluetoothMIDIDeviceStore }) => bluetoothMIDIDeviceStore.isLoading,
)
)
},
get requestError() {
return useMobxStore(({ midiDeviceStore }) => midiDeviceStore.requestError)
return (
useMobxStore(({ midiDeviceStore }) => midiDeviceStore.requestError) ||
useMobxStore(
({ bluetoothMIDIDeviceStore }) =>
bluetoothMIDIDeviceStore.requestError,
)
)
},
requestMIDIAccess: useCallback(() => {
midiDeviceStore.requestMIDIAccess()
}, [midiDeviceStore]),
requestBluetoothMIDIDevice: useCallback(() => {
bluetoothMIDIDeviceStore.requestDevice()
}, [bluetoothMIDIDeviceStore]),
setInputEnable: useCallback(
(deviceId: string, isEnabled: boolean) => {
midiDeviceStore.setInputEnable(deviceId, isEnabled)
if (btInputs.some((d) => d.id === deviceId)) {
bluetoothMIDIDeviceStore.setInputEnable(deviceId, isEnabled)
} else {
midiDeviceStore.setInputEnable(deviceId, isEnabled)
}
},
[midiDeviceStore],
[midiDeviceStore, bluetoothMIDIDeviceStore, btInputs],
),
setOutputEnable: useCallback(
(deviceId: string, isEnabled: boolean) => {
Expand Down
30 changes: 8 additions & 22 deletions app/src/services/MIDIInput.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,22 @@
export class MIDIInput {
private devices: WebMidi.MIDIInput[] = []
private listeners: ((e: WebMidi.MIDIMessageEvent) => void)[] = []

readonly removeAllDevices = () => {
this.devices.forEach(this.removeDevice)
}

readonly removeDevice = (device: WebMidi.MIDIInput) => {
device.removeEventListener(
"midimessage",
this.onMidiMessage as (e: Event) => void,
)
this.devices = this.devices.filter((d) => d.id !== device.id)
}
export interface MIDIInputEvent {
data: Uint8Array
}

readonly addDevice = (device: WebMidi.MIDIInput) => {
device.addEventListener("midimessage", this.onMidiMessage)
this.devices.push(device)
}
export class MIDIInput {
private listeners: ((e: MIDIInputEvent) => void)[] = []

readonly onMidiMessage = (e: WebMidi.MIDIMessageEvent) => {
readonly onMidiMessage = (e: MIDIInputEvent) => {
this.listeners.forEach((callback) => callback(e))
}

on(event: "midiMessage", callback: (e: WebMidi.MIDIMessageEvent) => void) {
on(event: "midiMessage", callback: (e: MIDIInputEvent) => void) {
this.listeners.push(callback)
return () => {
this.off(event, callback)
}
}

off(_event: "midiMessage", callback: (e: WebMidi.MIDIMessageEvent) => void) {
off(_event: "midiMessage", callback: (e: MIDIInputEvent) => void) {
this.listeners = this.listeners.filter((cb) => cb !== callback)
}
}
3 changes: 2 additions & 1 deletion app/src/services/MIDIMonitor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { Player } from "@signal-app/player"
import { deserializeSingleEvent, Stream } from "midifile-ts"
import { MIDIInputEvent } from "./MIDIInput"

export class MIDIMonitor {
channel: number = 0

constructor(private readonly player: Player) {}

onMessage(e: WebMidi.MIDIMessageEvent) {
onMessage(e: MIDIInputEvent) {
const stream = new Stream(e.data)
const event = deserializeSingleEvent(stream)

Expand Down
3 changes: 2 additions & 1 deletion app/src/services/MIDIRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { deserializeSingleEvent, Stream } from "midifile-ts"
import { makeObservable, observable, observe } from "mobx"
import { SongStore } from "../stores/SongStore"
import { NoteEvent, TrackEvent, TrackId, UNASSIGNED_TRACK_ID } from "../track"
import { MIDIInputEvent } from "./MIDIInput"

export class MIDIRecorder {
private recordedNotes: NoteEvent[] = []
Expand Down Expand Up @@ -52,7 +53,7 @@ export class MIDIRecorder {
})
}

onMessage(e: WebMidi.MIDIMessageEvent) {
onMessage(e: MIDIInputEvent) {
if (!this.isRecording) {
return
}
Expand Down
89 changes: 89 additions & 0 deletions app/src/stores/BluetoothMIDIDeviceStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { action, makeObservable, observable } from "mobx"
import { makePersistable } from "mobx-persist-store"
import { BLEMIDI, BLEMIDIDevice, MIDIMessageEvent } from "web-ble-midi"
import { MIDIInput } from "../services/MIDIInput"

export interface BluetoothMIDIDeviceInfo {
id: string
name: string
device: BluetoothDevice
}

export class BluetoothMIDIDeviceStore {
inputs: BLEMIDIDevice[] = []
requestError: Error | null = null
isLoading = false
enabledInputs: { [deviceId: string]: boolean } = {}

constructor(private readonly midiInput: MIDIInput) {
makeObservable(this, {
inputs: observable,
requestError: observable,
isLoading: observable,
enabledInputs: observable,
setInputEnable: action,
requestDevice: action,
})

makePersistable(this, {
name: "BluetoothMIDIDeviceStore",
properties: ["enabledInputs"],
storage: window.localStorage,
})
}

async setInputEnable(deviceId: string, enabled: boolean) {
const device = this.inputs.find((d) => d.id === deviceId)

if (!device) {
console.warn(`Device with id ${deviceId} not found`)
return
}

if (enabled && !device.isConnected()) {
await device.connect()

this.enabledInputs = {
...this.enabledInputs,
[deviceId]: true,
}
}

if (!enabled && device.isConnected()) {
device.disconnect()

this.enabledInputs = {
...this.enabledInputs,
[deviceId]: false,
}
}
}

// BLE MIDIデバイスのスキャン(ユーザー操作必須)
async requestDevice() {
this.isLoading = true
this.requestError = null
try {
const device = await BLEMIDI.scan()
if (!this.inputs.some((d) => d.id === device.id)) {
device.addEventListener("disconnect", () => {
this.inputs = this.inputs.filter((d) => d.id !== device.id)
})
device.addEventListener("midimessage", (event) => {
if (this.enabledInputs[device.id]) {
this.midiInput.onMidiMessage({
data: (event as MIDIMessageEvent).message.message.slice(0),
// timeStamp: message.timestampMs,
})
}
})
this.inputs.push(device)
}
await this.setInputEnable(device.id, true)
} catch (e) {
this.requestError = e as Error
} finally {
this.isLoading = false
}
}
}
10 changes: 9 additions & 1 deletion app/src/stores/MIDIDeviceStore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { action, makeObservable, observable } from "mobx"
import { makePersistable } from "mobx-persist-store"
import { MIDIInput } from "../services/MIDIInput"

export class MIDIDeviceStore {
inputs: WebMidi.MIDIInput[] = []
Expand All @@ -10,7 +11,7 @@ export class MIDIDeviceStore {
enabledInputs: { [deviceId: string]: boolean } = {}
isFactorySoundEnabled = true

constructor() {
constructor(private readonly midiInput: MIDIInput) {
makeObservable(this, {
inputs: observable,
outputs: observable,
Expand Down Expand Up @@ -54,6 +55,13 @@ export class MIDIDeviceStore {
midiAccess.onstatechange = () => {
this.updatePorts(midiAccess)
}
for (const input of midiAccess.inputs.values()) {
input.onmidimessage = (event) => {
if (this.enabledInputs[input.id]) {
this.midiInput.onMidiMessage(event)
}
}
}
} catch (error) {
this.requestError = error as Error
} finally {
Expand Down
6 changes: 5 additions & 1 deletion app/src/stores/RootStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { MIDIInput } from "../services/MIDIInput"
import { MIDIMonitor } from "../services/MIDIMonitor"
import { MIDIRecorder } from "../services/MIDIRecorder"
import { SerializedArrangeViewStore } from "./ArrangeViewStore"
import { BluetoothMIDIDeviceStore } from "./BluetoothMIDIDeviceStore"
import { SerializedControlStore } from "./ControlStore"
import { MIDIDeviceStore } from "./MIDIDeviceStore"
import { SerializedPianoRollStore } from "./PianoRollStore"
Expand All @@ -25,7 +26,7 @@ export interface SerializedRootStore {

export default class RootStore {
readonly songStore = new SongStore()
readonly midiDeviceStore = new MIDIDeviceStore()
readonly midiDeviceStore: MIDIDeviceStore
readonly player: Player
readonly synth: SoundFontSynth
readonly metronomeSynth: SoundFontSynth
Expand All @@ -34,6 +35,7 @@ export default class RootStore {
readonly midiRecorder: MIDIRecorder
readonly midiMonitor: MIDIMonitor
readonly soundFontStore: SoundFontStore
readonly bluetoothMIDIDeviceStore: BluetoothMIDIDeviceStore

constructor() {
const context = new (window.AudioContext || window.webkitAudioContext)()
Expand All @@ -49,6 +51,8 @@ export default class RootStore {

this.midiRecorder = new MIDIRecorder(this.songStore, this.player)
this.midiMonitor = new MIDIMonitor(this.player)
this.midiDeviceStore = new MIDIDeviceStore(this.midiInput)
this.bluetoothMIDIDeviceStore = new BluetoothMIDIDeviceStore(this.midiInput)

this.midiInput.on("midiMessage", (e) => {
this.midiMonitor.onMessage(e)
Expand Down
Loading