Release MIDI Tools 0.2.0
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
- name: Install browser engines
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Browser tests
|
||||
run: npm run test:browser
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Moved bounded MIDI parsing, transforms and export into cancellable worker jobs
|
||||
with progress and stale-result suppression.
|
||||
- Added paired-note editing/deletion, transposition and quantization controls.
|
||||
- Added explicit opt-in, sysex-disabled Web MIDI output and bounded local
|
||||
SoundFont metadata inspection without claiming SoundFont synthesis.
|
||||
- Declared MIDI/SoundFont input, export and optional browser capability profiles
|
||||
for Toolbox handoff.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added bounded Standard MIDI type 0/1 parsing and deterministic MIDI export.
|
||||
|
||||
@@ -5,21 +5,27 @@ Parse, visualize, edit, audition and export Standard MIDI files locally in the b
|
||||
## Features
|
||||
|
||||
- Strict, bounded Standard MIDI file type 0 and type 1 parsing with running status, canonical VLQs, channel events, metadata and bounded SysEx.
|
||||
- Disposable module-worker parsing, editing and MIDI/CSV/JSON export with
|
||||
progress, explicit cancellation and stale-result suppression.
|
||||
- Track/event inspection, tempo, time-signature and key-signature timelines, a piano roll and pitch-class keyboard.
|
||||
- Immediate-stop Web Audio timing preview using a small built-in sine synthesizer.
|
||||
- Transpose, note quantize, constant-tempo, crop and channel remap/remove operations with twenty-step undo.
|
||||
- Immediate-stop Web Audio timing preview using a small built-in sine synthesizer,
|
||||
plus explicitly enabled Web MIDI output without SysEx permission.
|
||||
- Click-to-edit paired notes, transpose, note quantize, constant-tempo, crop and
|
||||
channel remap/remove operations with twenty-step undo.
|
||||
- Bounded local SF2/SF3 RIFF structure inspection. SoundFont rendering is
|
||||
deliberately not claimed until a vetted local synthesizer is bundled.
|
||||
- Deterministic valid MIDI export plus spreadsheet-safe CSV and descriptive JSON reports.
|
||||
- Offline PWA and shared Toolbox system/light/dark shell.
|
||||
|
||||
## Limits and editing semantics
|
||||
|
||||
Input and output are capped at 8 MiB, 256 tracks and 250,000 events; aggregate SysEx is capped at 1 MiB. Only PPQN timing is supported. Playback schedules at most 2,000 notes, considers the first 900 seconds and caps any sounding note at 30 seconds. It is a timing preview, not a General MIDI instrument or soundfont renderer.
|
||||
Input and output are capped at 8 MiB, 256 tracks and 250,000 events; aggregate SysEx is capped at 1 MiB. Only PPQN timing is supported. Playback schedules at most 2,000 notes, considers the first 900 seconds and caps any sounding note at 30 seconds. Web MIDI is an explicit browser permission and sends only channel note messages to the selected output. SF2/SF3 inspection is capped at 128 MiB and never loads a remote bank. The built-in player is a timing preview, not a General MIDI instrument or SoundFont renderer.
|
||||
|
||||
Transpose updates note, note-off and poly-pressure pitches but deliberately does not rewrite key signatures. Quantize moves paired note boundaries to ticks. Crop clips overlapping paired notes, retains the latest tempo/time/key, program and controller setup, then shifts to tick zero. MIDI export writes full status bytes, canonical VLQs and one end-of-track event per track.
|
||||
|
||||
## Development
|
||||
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/midi-tools-0.1.0.zip` with a SHA-256 sidecar.
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/midi-tools-0.2.0.zip` with a SHA-256 sidecar.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+12
-2
@@ -1,5 +1,15 @@
|
||||
# Security policy
|
||||
|
||||
Report vulnerabilities privately to the repository owner. Supported version: 0.1.x.
|
||||
Report vulnerabilities privately to the repository owner. Supported version: 0.2.x.
|
||||
|
||||
MIDI Tools makes no uploads, telemetry or third-party requests. JSON is parsed as inert data under text, depth, node and token-count bounds. Exporters escape Android XML and quote Swift strings. Generated source remains an input to another toolchain and must be reviewed before production use.
|
||||
MIDI Tools makes no uploads, telemetry or third-party requests. MIDI and
|
||||
SoundFont files are treated as untrusted binary data under the documented size,
|
||||
track, event, SysEx and RIFF-chunk bounds. Worker results are revision-scoped so
|
||||
cancelled or superseded jobs cannot replace newer state.
|
||||
|
||||
Web MIDI access is requested only after an explicit click and always with SysEx
|
||||
permission disabled. The app does not subscribe to MIDI inputs; playback sends
|
||||
only bounded channel messages to the output selected by the user, and Stop
|
||||
clears queued messages and sends all-notes-off on every channel. SoundFont files
|
||||
are inspected locally for bounded RIFF metadata and are never synthesized,
|
||||
executed, fetched remotely or sent to a device.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Source identity
|
||||
|
||||
- Project: MIDI Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Repository: https://git.add-ideas.de/lotobo/midi-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
||||
- Artifact: `midi-tools-0.1.0.zip`
|
||||
- Artifact: `midi-tools-0.2.0.zip`
|
||||
|
||||
The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Third-party notices
|
||||
|
||||
MIDI Tools is GPL-3.0-or-later. Runtime dependencies are React, React DOM and the add·ideas Toolbox Contract, Shell and Helpers packages. Exact versions and declared licences are recorded in `package-lock.json`; detected runtime licence texts are generated into release archives at `LICENSES/npm-runtime-licenses.txt`.
|
||||
MIDI Tools is GPL-3.0-or-later. Runtime dependencies are React and React DOM
|
||||
(MIT), add·ideas Toolbox Contract and Shell React 0.3.0 (Apache-2.0), and
|
||||
Toolbox Helpers 0.2.0 (GPL-3.0-or-later). Exact versions and declared licences
|
||||
are recorded in `package-lock.json`; detected runtime licence texts are
|
||||
generated into release archives at `LICENSES/npm-runtime-licenses.txt`.
|
||||
|
||||
“MIDI” refers to the Standard MIDI File data format. This project is independent and does not imply endorsement by the MIDI Association.
|
||||
|
||||
+15
-1
@@ -2,6 +2,20 @@
|
||||
|
||||
`core/midi.ts` turns a bounded SMF byte stream into inert tracks and absolute-tick events. Chunk boundaries, four-byte VLQs, running status, event counts, SysEx size, PPQN division and known fixed-length meta events are validated before data is exposed. Editing functions clone this model and preserve stable event ordering.
|
||||
|
||||
`Workbench.tsx` keeps the last valid document during failed imports or operations. The SVG piano roll renders at most 5,000 notes. Playback maps ticks through the tempo map, creates fixed application-owned sine oscillators and can stop immediately by closing the active `AudioContext`; imported bytes never become code or remote references.
|
||||
`Workbench.tsx` keeps the last valid document during failed imports or
|
||||
operations. Parsing, transforms and exports execute through a disposable module
|
||||
worker; progress messages are revision-scoped, cancellation terminates the
|
||||
worker and late messages cannot replace newer state. A no-Worker fallback exists
|
||||
for test and older-browser resilience, while the shipped browser path stays
|
||||
off-main. The SVG piano roll renders at most 5,000 notes. Playback maps ticks
|
||||
through the tempo map, creates fixed application-owned sine oscillators and can
|
||||
stop immediately by closing the active `AudioContext`; imported bytes never
|
||||
become code or remote references.
|
||||
|
||||
Paired notes can be selected in the SVG piano roll and edited through the same
|
||||
worker protocol as bulk transforms. Web MIDI access is requested only after an
|
||||
explicit click, without SysEx permission; scheduled output is bounded and Stop
|
||||
sends all-notes-off on every channel. The local SoundFont inspector validates
|
||||
RIFF `sfbk` structure and reports INFO/pdta counts but does not render the bank.
|
||||
|
||||
All Vite assets use relative paths for nested portal mounting. Release packaging fixes ZIP ordering, timestamps and permissions.
|
||||
|
||||
@@ -2,4 +2,16 @@
|
||||
|
||||
MIDI bytes are read into browser memory only. The application has no telemetry, account, database, remote asset or network client. CSP restricts connections and media to the application origin or local blobs.
|
||||
|
||||
Parsing and export enforce an 8 MiB file bound, 256 tracks, 250,000 events, a 1 MiB aggregate SysEx bound, four-byte VLQs and signed-safe absolute ticks. Playback is explicitly user started, schedules at most 2,000 notes and is stopped by closing its audio context. Imported text appears only as escaped React text and is never interpreted as markup, script, URL or shader.
|
||||
Parsing and export enforce an 8 MiB file bound, 256 tracks, 250,000 events, a
|
||||
1 MiB aggregate SysEx bound, four-byte VLQs and signed-safe absolute ticks.
|
||||
Worker messages are revision-scoped, and cancellation terminates the disposable
|
||||
worker before stale results can update the document.
|
||||
|
||||
Web Audio and Web MIDI playback are explicitly user started and schedule at most
|
||||
2,000 notes over the first 900 seconds. Web MIDI permission is requested with
|
||||
SysEx disabled; the app does not subscribe to MIDI inputs and sends only channel
|
||||
note messages to the selected output. Stop clears scheduled output and sends
|
||||
all-notes-off on every channel. Local SoundFont inspection is capped at 128 MiB,
|
||||
validates bounded RIFF structure and reports metadata only; it neither
|
||||
synthesizes the bank nor resolves remote resources. Imported text appears only
|
||||
as escaped React text and is never interpreted as markup, script, URL or shader.
|
||||
|
||||
Generated
+20
-20
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "midi-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "midi-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -42,24 +42,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -67,13 +67,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "midi-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Parse, visualize, edit, play and export Standard MIDI files locally in the browser.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -39,14 +39,14 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -15,7 +15,25 @@ export default defineConfig({
|
||||
timeout: 180_000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
testMatch: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Moved bounded MIDI parsing, transforms and export into cancellable worker jobs
|
||||
with progress and stale-result suppression.
|
||||
- Added paired-note editing/deletion, transposition and quantization controls.
|
||||
- Added explicit opt-in, sysex-disabled Web MIDI output and bounded local
|
||||
SoundFont metadata inspection without claiming SoundFont synthesis.
|
||||
- Declared MIDI/SoundFont input, export and optional browser capability profiles
|
||||
for Toolbox handoff.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Added bounded Standard MIDI type 0/1 parsing and deterministic MIDI export.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-contract@0.2.3
|
||||
@add-ideas/toolbox-contract@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-helpers@0.1.0
|
||||
@add-ideas/toolbox-helpers@0.2.0
|
||||
Declared licence: GPL-3.0-or-later
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-shell-react@0.2.3
|
||||
@add-ideas/toolbox-shell-react@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
|
||||
+10
-4
@@ -5,21 +5,27 @@ Parse, visualize, edit, audition and export Standard MIDI files locally in the b
|
||||
## Features
|
||||
|
||||
- Strict, bounded Standard MIDI file type 0 and type 1 parsing with running status, canonical VLQs, channel events, metadata and bounded SysEx.
|
||||
- Disposable module-worker parsing, editing and MIDI/CSV/JSON export with
|
||||
progress, explicit cancellation and stale-result suppression.
|
||||
- Track/event inspection, tempo, time-signature and key-signature timelines, a piano roll and pitch-class keyboard.
|
||||
- Immediate-stop Web Audio timing preview using a small built-in sine synthesizer.
|
||||
- Transpose, note quantize, constant-tempo, crop and channel remap/remove operations with twenty-step undo.
|
||||
- Immediate-stop Web Audio timing preview using a small built-in sine synthesizer,
|
||||
plus explicitly enabled Web MIDI output without SysEx permission.
|
||||
- Click-to-edit paired notes, transpose, note quantize, constant-tempo, crop and
|
||||
channel remap/remove operations with twenty-step undo.
|
||||
- Bounded local SF2/SF3 RIFF structure inspection. SoundFont rendering is
|
||||
deliberately not claimed until a vetted local synthesizer is bundled.
|
||||
- Deterministic valid MIDI export plus spreadsheet-safe CSV and descriptive JSON reports.
|
||||
- Offline PWA and shared Toolbox system/light/dark shell.
|
||||
|
||||
## Limits and editing semantics
|
||||
|
||||
Input and output are capped at 8 MiB, 256 tracks and 250,000 events; aggregate SysEx is capped at 1 MiB. Only PPQN timing is supported. Playback schedules at most 2,000 notes, considers the first 900 seconds and caps any sounding note at 30 seconds. It is a timing preview, not a General MIDI instrument or soundfont renderer.
|
||||
Input and output are capped at 8 MiB, 256 tracks and 250,000 events; aggregate SysEx is capped at 1 MiB. Only PPQN timing is supported. Playback schedules at most 2,000 notes, considers the first 900 seconds and caps any sounding note at 30 seconds. Web MIDI is an explicit browser permission and sends only channel note messages to the selected output. SF2/SF3 inspection is capped at 128 MiB and never loads a remote bank. The built-in player is a timing preview, not a General MIDI instrument or SoundFont renderer.
|
||||
|
||||
Transpose updates note, note-off and poly-pressure pitches but deliberately does not rewrite key signatures. Quantize moves paired note boundaries to ticks. Crop clips overlapping paired notes, retains the latest tempo/time/key, program and controller setup, then shifts to tick zero. MIDI export writes full status bytes, canonical VLQs and one end-of-track event per track.
|
||||
|
||||
## Development
|
||||
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/midi-tools-0.1.0.zip` with a SHA-256 sidecar.
|
||||
Use Node.js 22+ and npm 11. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`. The deterministic artifact is `release/midi-tools-0.2.0.zip` with a SHA-256 sidecar.
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
+12
-2
@@ -1,5 +1,15 @@
|
||||
# Security policy
|
||||
|
||||
Report vulnerabilities privately to the repository owner. Supported version: 0.1.x.
|
||||
Report vulnerabilities privately to the repository owner. Supported version: 0.2.x.
|
||||
|
||||
MIDI Tools makes no uploads, telemetry or third-party requests. JSON is parsed as inert data under text, depth, node and token-count bounds. Exporters escape Android XML and quote Swift strings. Generated source remains an input to another toolchain and must be reviewed before production use.
|
||||
MIDI Tools makes no uploads, telemetry or third-party requests. MIDI and
|
||||
SoundFont files are treated as untrusted binary data under the documented size,
|
||||
track, event, SysEx and RIFF-chunk bounds. Worker results are revision-scoped so
|
||||
cancelled or superseded jobs cannot replace newer state.
|
||||
|
||||
Web MIDI access is requested only after an explicit click and always with SysEx
|
||||
permission disabled. The app does not subscribe to MIDI inputs; playback sends
|
||||
only bounded channel messages to the output selected by the user, and Stop
|
||||
clears queued messages and sends all-notes-off on every channel. SoundFont files
|
||||
are inspected locally for bounded RIFF metadata and are never synthesized,
|
||||
executed, fetched remotely or sent to a device.
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
# Source identity
|
||||
|
||||
- Project: MIDI Tools
|
||||
- Version: 0.1.0
|
||||
- Version: 0.2.0
|
||||
- Repository: https://git.add-ideas.de/lotobo/midi-tools
|
||||
- Licence: GPL-3.0-or-later
|
||||
- Build: Node.js 22+, npm 11, `npm ci && npm run release:artifact`
|
||||
- Artifact: `midi-tools-0.1.0.zip`
|
||||
- Artifact: `midi-tools-0.2.0.zip`
|
||||
|
||||
The lockfile pins dependencies exactly. Generated release manifests repeat this source identity and releases include detected runtime licence texts under `LICENSES/`.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Third-party notices
|
||||
|
||||
MIDI Tools is GPL-3.0-or-later. Runtime dependencies are React, React DOM and the add·ideas Toolbox Contract, Shell and Helpers packages. Exact versions and declared licences are recorded in `package-lock.json`; detected runtime licence texts are generated into release archives at `LICENSES/npm-runtime-licenses.txt`.
|
||||
MIDI Tools is GPL-3.0-or-later. Runtime dependencies are React and React DOM
|
||||
(MIT), add·ideas Toolbox Contract and Shell React 0.3.0 (Apache-2.0), and
|
||||
Toolbox Helpers 0.2.0 (GPL-3.0-or-later). Exact versions and declared licences
|
||||
are recorded in `package-lock.json`; detected runtime licence texts are
|
||||
generated into release archives at `LICENSES/npm-runtime-licenses.txt`.
|
||||
|
||||
“MIDI” refers to the Standard MIDI File data format. This project is independent and does not imply endorsement by the MIDI Association.
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
|
||||
`core/midi.ts` turns a bounded SMF byte stream into inert tracks and absolute-tick events. Chunk boundaries, four-byte VLQs, running status, event counts, SysEx size, PPQN division and known fixed-length meta events are validated before data is exposed. Editing functions clone this model and preserve stable event ordering.
|
||||
|
||||
`Workbench.tsx` keeps the last valid document during failed imports or operations. The SVG piano roll renders at most 5,000 notes. Playback maps ticks through the tempo map, creates fixed application-owned sine oscillators and can stop immediately by closing the active `AudioContext`; imported bytes never become code or remote references.
|
||||
`Workbench.tsx` keeps the last valid document during failed imports or
|
||||
operations. Parsing, transforms and exports execute through a disposable module
|
||||
worker; progress messages are revision-scoped, cancellation terminates the
|
||||
worker and late messages cannot replace newer state. A no-Worker fallback exists
|
||||
for test and older-browser resilience, while the shipped browser path stays
|
||||
off-main. The SVG piano roll renders at most 5,000 notes. Playback maps ticks
|
||||
through the tempo map, creates fixed application-owned sine oscillators and can
|
||||
stop immediately by closing the active `AudioContext`; imported bytes never
|
||||
become code or remote references.
|
||||
|
||||
Paired notes can be selected in the SVG piano roll and edited through the same
|
||||
worker protocol as bulk transforms. Web MIDI access is requested only after an
|
||||
explicit click, without SysEx permission; scheduled output is bounded and Stop
|
||||
sends all-notes-off on every channel. The local SoundFont inspector validates
|
||||
RIFF `sfbk` structure and reports INFO/pdta counts but does not render the bank.
|
||||
|
||||
All Vite assets use relative paths for nested portal mounting. Release packaging fixes ZIP ordering, timestamps and permissions.
|
||||
|
||||
@@ -2,4 +2,16 @@
|
||||
|
||||
MIDI bytes are read into browser memory only. The application has no telemetry, account, database, remote asset or network client. CSP restricts connections and media to the application origin or local blobs.
|
||||
|
||||
Parsing and export enforce an 8 MiB file bound, 256 tracks, 250,000 events, a 1 MiB aggregate SysEx bound, four-byte VLQs and signed-safe absolute ticks. Playback is explicitly user started, schedules at most 2,000 notes and is stopped by closing its audio context. Imported text appears only as escaped React text and is never interpreted as markup, script, URL or shader.
|
||||
Parsing and export enforce an 8 MiB file bound, 256 tracks, 250,000 events, a
|
||||
1 MiB aggregate SysEx bound, four-byte VLQs and signed-safe absolute ticks.
|
||||
Worker messages are revision-scoped, and cancellation terminates the disposable
|
||||
worker before stale results can update the document.
|
||||
|
||||
Web Audio and Web MIDI playback are explicitly user started and schedule at most
|
||||
2,000 notes over the first 900 seconds. Web MIDI permission is requested with
|
||||
SysEx disabled; the app does not subscribe to MIDI inputs and sends only channel
|
||||
note messages to the selected output. Stop clears scheduled output and sends
|
||||
all-notes-off on every channel. Local SoundFont inspection is capped at 128 MiB,
|
||||
validates bounded RIFF structure and reports metadata only; it neither
|
||||
synthesizes the bank nor resolves remote resources. Imported text appears only
|
||||
as escaped React text and is never interpreted as markup, script, URL or shader.
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
const CACHE_PREFIX = "midi-tools-shell-";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.1.0";
|
||||
const CACHE_NAME = CACHE_PREFIX + "0.2.0";
|
||||
const CORE = ["./", "./manifest.webmanifest", "./favicon.svg"];
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
|
||||
+24
-2
@@ -3,12 +3,19 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.midi-tools",
|
||||
"name": "MIDI Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect, edit and play MIDI locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["audio", "media", "developer"],
|
||||
"tags": ["midi", "piano roll", "tempo", "sequencer", "web audio"],
|
||||
"tags": [
|
||||
"midi",
|
||||
"piano roll",
|
||||
"tempo",
|
||||
"sequencer",
|
||||
"web audio",
|
||||
"soundfont"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +28,21 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{ "mediaType": "audio/midi", "extensions": [".mid", ".midi"] },
|
||||
{ "mediaType": "audio/x-soundfont", "extensions": [".sf2", ".sf3"] }
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "audio/midi", "extensions": [".mid"] },
|
||||
{ "mediaType": "text/csv", "extensions": [".csv"] },
|
||||
{ "mediaType": "application/json", "extensions": [".json"] }
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["workers", "web-audio", "web-midi"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
@@ -45,7 +45,12 @@ export function HelpDialog({
|
||||
preview, not a General MIDI soundfont renderer.
|
||||
</p>
|
||||
<p>
|
||||
SMPTE time division is not supported in v0.1.0. Transpose does not
|
||||
Web MIDI is optional and requested only from its button, without SysEx
|
||||
access. SF2/SF3 files can be structurally inspected locally, but are not
|
||||
used for synthesis in this release.
|
||||
</p>
|
||||
<p>
|
||||
SMPTE time division is not supported in v0.2.0. Transpose does not
|
||||
rewrite key signatures and crop retains only essential setup events.
|
||||
</p>
|
||||
</dialog>
|
||||
|
||||
@@ -5,9 +5,13 @@ const MAX_RENDERED_NOTES = 5_000;
|
||||
export function PianoRoll({
|
||||
notes,
|
||||
durationTicks,
|
||||
selectedOrder,
|
||||
onSelect,
|
||||
}: {
|
||||
notes: NoteSpan[];
|
||||
durationTicks: number;
|
||||
selectedOrder?: number;
|
||||
onSelect?: (note: NoteSpan) => void;
|
||||
}) {
|
||||
const visible = notes.slice(0, MAX_RENDERED_NOTES);
|
||||
const minimum = Math.min(36, ...visible.map((note) => note.note));
|
||||
@@ -68,8 +72,25 @@ export function PianoRoll({
|
||||
y={y + 1}
|
||||
width={noteWidth}
|
||||
height={Math.max(2, height / pitchRange - 2)}
|
||||
className="roll-note"
|
||||
className={
|
||||
"roll-note" +
|
||||
(note.onOrder === selectedOrder ? " selected" : "")
|
||||
}
|
||||
style={{ opacity: 0.45 + (note.velocity / 127) * 0.55 }}
|
||||
role={onSelect ? "button" : undefined}
|
||||
tabIndex={onSelect ? 0 : undefined}
|
||||
aria-label={
|
||||
onSelect
|
||||
? `Edit note ${note.note} at tick ${note.startTick}`
|
||||
: undefined
|
||||
}
|
||||
onClick={() => onSelect?.(note)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect?.(note);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<title>
|
||||
{"Note " +
|
||||
|
||||
+445
-56
@@ -1,25 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
|
||||
import {
|
||||
constantTempoMidi,
|
||||
cropMidi,
|
||||
dropChannel,
|
||||
encodeMidi,
|
||||
eventDetail,
|
||||
eventLabel,
|
||||
exportMidiCsv,
|
||||
exportMidiJson,
|
||||
MAX_MIDI_BYTES,
|
||||
noteSpans,
|
||||
parseMidi,
|
||||
playbackNotes,
|
||||
quantizeMidi,
|
||||
remapChannel,
|
||||
summarizeMidi,
|
||||
transposeMidi,
|
||||
type MidiDocument,
|
||||
type MidiEvent,
|
||||
} from "../core/midi";
|
||||
import {
|
||||
runMidiWorker,
|
||||
type MidiWorkerJob,
|
||||
type MidiWorkerProgress,
|
||||
} from "../core/midi-worker-client";
|
||||
import type {
|
||||
MidiOperation,
|
||||
MidiTask,
|
||||
MidiTaskResult,
|
||||
} from "../core/midi-task";
|
||||
import {
|
||||
inspectSoundFont,
|
||||
MAX_SOUNDFONT_BYTES,
|
||||
type SoundFontSummary,
|
||||
} from "../core/soundfont";
|
||||
import { allNotesOffMessages, webMidiSchedule } from "../core/web-midi";
|
||||
import { PianoRoll } from "./PianoRoll";
|
||||
|
||||
const EXAMPLE: MidiDocument = {
|
||||
@@ -96,6 +102,7 @@ const EXAMPLE: MidiDocument = {
|
||||
};
|
||||
|
||||
type Playback = { context: AudioContext; timer: number };
|
||||
type MidiPlayback = { output: MIDIOutput; timer: number };
|
||||
|
||||
export function Workbench() {
|
||||
const [document, setDocument] = useState<MidiDocument>(EXAMPLE);
|
||||
@@ -104,6 +111,7 @@ export function Workbench() {
|
||||
"Built-in type 1 example loaded. Choose a local .mid file to inspect.",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState<MidiWorkerProgress>();
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [transpose, setTranspose] = useState("0");
|
||||
const [grid, setGrid] = useState("120");
|
||||
@@ -115,10 +123,27 @@ export function Workbench() {
|
||||
const [trackFilter, setTrackFilter] = useState("all");
|
||||
const [channelFilter, setChannelFilter] = useState("all");
|
||||
const [eventFilter, setEventFilter] = useState("");
|
||||
const [selectedNoteOrder, setSelectedNoteOrder] = useState<number>();
|
||||
const [notePitch, setNotePitch] = useState("60");
|
||||
const [noteVelocity, setNoteVelocity] = useState("100");
|
||||
const [noteStart, setNoteStart] = useState("0");
|
||||
const [noteEnd, setNoteEnd] = useState("480");
|
||||
const [midiAccess, setMidiAccess] = useState<MIDIAccess>();
|
||||
const [midiOutputs, setMidiOutputs] = useState<MIDIOutput[]>([]);
|
||||
const [midiOutputId, setMidiOutputId] = useState("");
|
||||
const [soundFont, setSoundFont] = useState<SoundFontSummary>();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const soundFontRef = useRef<HTMLInputElement>(null);
|
||||
const playbackRef = useRef<Playback | null>(null);
|
||||
const midiPlaybackRef = useRef<MidiPlayback | null>(null);
|
||||
const workerRef = useRef<MidiWorkerJob | undefined>(undefined);
|
||||
const workerRevision = useRef(0);
|
||||
const summary = useMemo(() => summarizeMidi(document), [document]);
|
||||
const notes = useMemo(() => noteSpans(document), [document]);
|
||||
const selectedNote = useMemo(
|
||||
() => notes.find((note) => note.onOrder === selectedNoteOrder),
|
||||
[notes, selectedNoteOrder],
|
||||
);
|
||||
|
||||
const stopPlayback = useCallback(
|
||||
(message: string | null = "Playback stopped immediately.") => {
|
||||
@@ -128,6 +153,13 @@ export function Workbench() {
|
||||
void active.context.close();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
const midi = midiPlaybackRef.current;
|
||||
if (midi) {
|
||||
window.clearTimeout(midi.timer);
|
||||
clearMidiQueue(midi.output);
|
||||
for (const message of allNotesOffMessages()) midi.output.send(message);
|
||||
midiPlaybackRef.current = null;
|
||||
}
|
||||
setIsPlaying(false);
|
||||
if (message !== null) setStatus(message);
|
||||
},
|
||||
@@ -136,15 +168,72 @@ export function Workbench() {
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
workerRevision.current += 1;
|
||||
workerRef.current?.cancel();
|
||||
const active = playbackRef.current;
|
||||
if (!active) return;
|
||||
if (active) {
|
||||
window.clearTimeout(active.timer);
|
||||
void active.context.close();
|
||||
playbackRef.current = null;
|
||||
}
|
||||
const midi = midiPlaybackRef.current;
|
||||
if (midi) {
|
||||
window.clearTimeout(midi.timer);
|
||||
clearMidiQueue(midi.output);
|
||||
for (const message of allNotesOffMessages()) midi.output.send(message);
|
||||
midiPlaybackRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!midiAccess) return;
|
||||
const update = () => setMidiOutputs([...midiAccess.outputs.values()]);
|
||||
midiAccess.addEventListener("statechange", update);
|
||||
return () => midiAccess.removeEventListener("statechange", update);
|
||||
}, [midiAccess]);
|
||||
|
||||
function cancelWorker(
|
||||
message = "MIDI job cancelled; the current document was retained.",
|
||||
) {
|
||||
workerRevision.current += 1;
|
||||
workerRef.current?.cancel();
|
||||
workerRef.current = undefined;
|
||||
setBusy(false);
|
||||
setProgress(undefined);
|
||||
setStatus(message);
|
||||
}
|
||||
|
||||
async function executeTask(
|
||||
task: MidiTask,
|
||||
revision = ++workerRevision.current,
|
||||
): Promise<MidiTaskResult | undefined> {
|
||||
workerRef.current?.cancel();
|
||||
setBusy(true);
|
||||
const job = runMidiWorker(task, (update) => {
|
||||
if (workerRevision.current === revision) setProgress(update);
|
||||
});
|
||||
workerRef.current = job;
|
||||
try {
|
||||
const result = await job.promise;
|
||||
return workerRevision.current === revision ? result : undefined;
|
||||
} catch (caught) {
|
||||
if (
|
||||
workerRevision.current === revision &&
|
||||
!(caught instanceof DOMException && caught.name === "AbortError")
|
||||
)
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
return undefined;
|
||||
} finally {
|
||||
if (workerRevision.current === revision) {
|
||||
workerRef.current = undefined;
|
||||
setBusy(false);
|
||||
setProgress(undefined);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function commit(next: MidiDocument, message: string): void {
|
||||
stopPlayback(null);
|
||||
setHistory((current) => [...current.slice(-19), document]);
|
||||
@@ -155,14 +244,21 @@ export function Workbench() {
|
||||
|
||||
async function openFile(file: File | undefined): Promise<void> {
|
||||
if (!file) return;
|
||||
const revision = ++workerRevision.current;
|
||||
workerRef.current?.cancel();
|
||||
setBusy(true);
|
||||
setProgress({ progress: 0, stage: "Reading local MIDI" });
|
||||
try {
|
||||
if (file.size > MAX_MIDI_BYTES)
|
||||
throw new RangeError("MIDI files are limited to 8 MiB.");
|
||||
const next = parseMidi(
|
||||
file.name,
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
const bytes = await file.arrayBuffer();
|
||||
if (workerRevision.current !== revision) return;
|
||||
const result = await executeTask(
|
||||
{ kind: "parse", name: file.name, bytes },
|
||||
revision,
|
||||
);
|
||||
if (!result || result.kind !== "document") return;
|
||||
const next = result.document;
|
||||
stopPlayback(null);
|
||||
setDocument(next);
|
||||
setHistory([]);
|
||||
@@ -177,9 +273,13 @@ export function Workbench() {
|
||||
" track(s).",
|
||||
);
|
||||
} catch (caught) {
|
||||
if (workerRevision.current === revision)
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
if (workerRevision.current === revision && !workerRef.current) {
|
||||
setBusy(false);
|
||||
setProgress(undefined);
|
||||
}
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
@@ -244,6 +344,83 @@ export function Workbench() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectNote(note: (typeof notes)[number]): void {
|
||||
setSelectedNoteOrder(note.onOrder);
|
||||
setNotePitch(String(note.note));
|
||||
setNoteVelocity(String(note.velocity));
|
||||
setNoteStart(String(note.startTick));
|
||||
setNoteEnd(String(note.endTick));
|
||||
}
|
||||
|
||||
async function enableWebMidi(): Promise<void> {
|
||||
try {
|
||||
if (!window.isSecureContext)
|
||||
throw new Error(
|
||||
"Web MIDI requires a secure HTTPS or localhost context.",
|
||||
);
|
||||
if (!("requestMIDIAccess" in navigator))
|
||||
throw new Error("This browser does not expose the Web MIDI API.");
|
||||
const access = await navigator.requestMIDIAccess({ sysex: false });
|
||||
setMidiAccess(access);
|
||||
const outputs = [...access.outputs.values()];
|
||||
setMidiOutputs(outputs);
|
||||
const first = outputs[0];
|
||||
if (first) setMidiOutputId(first.id);
|
||||
setStatus(
|
||||
first
|
||||
? "Web MIDI permission granted without SysEx access."
|
||||
: "Web MIDI permission granted, but no output is connected.",
|
||||
);
|
||||
} catch (caught) {
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
}
|
||||
}
|
||||
|
||||
async function playWebMidi(): Promise<void> {
|
||||
stopPlayback(null);
|
||||
try {
|
||||
const output = midiAccess?.outputs.get(midiOutputId);
|
||||
if (!output) throw new Error("Choose a connected Web MIDI output first.");
|
||||
await output.open();
|
||||
const schedule = webMidiSchedule(document);
|
||||
const start = performance.now() + 50;
|
||||
for (const message of schedule)
|
||||
output.send(message.data, start + message.offsetMs);
|
||||
const duration = (schedule.at(-1)?.offsetMs ?? 0) + 200;
|
||||
const timer = window.setTimeout(
|
||||
() => stopPlayback("Web MIDI playback finished."),
|
||||
duration,
|
||||
);
|
||||
midiPlaybackRef.current = { output, timer };
|
||||
setIsPlaying(true);
|
||||
setStatus(
|
||||
`Scheduled ${(schedule.length / 2).toLocaleString()} note(s) to ${output.name ?? "the selected output"}.`,
|
||||
);
|
||||
} catch (caught) {
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
}
|
||||
}
|
||||
|
||||
async function openSoundFont(file: File | undefined): Promise<void> {
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > MAX_SOUNDFONT_BYTES)
|
||||
throw new RangeError("SoundFont inspection is limited to 128 MiB.");
|
||||
const result = inspectSoundFont(
|
||||
file.name,
|
||||
new Uint8Array(await file.arrayBuffer()),
|
||||
);
|
||||
setSoundFont(result);
|
||||
setStatus(
|
||||
"Inspected the local SoundFont structure. It was not loaded into the synthesizer.",
|
||||
);
|
||||
} catch (caught) {
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
} finally {
|
||||
if (soundFontRef.current) soundFontRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() =>
|
||||
document.tracks
|
||||
@@ -299,9 +476,9 @@ export function Workbench() {
|
||||
disabled={busy}
|
||||
onClick={() => inputRef.current?.click()}
|
||||
>
|
||||
{busy ? "Parsing…" : "Choose local MIDI"}
|
||||
Choose local MIDI
|
||||
</button>
|
||||
<button type="button" onClick={() => void play()}>
|
||||
<button type="button" disabled={busy} onClick={() => void play()}>
|
||||
Play from start
|
||||
</button>
|
||||
<button
|
||||
@@ -313,7 +490,7 @@ export function Workbench() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={history.length === 0}
|
||||
disabled={busy || history.length === 0}
|
||||
onClick={() => {
|
||||
const previous = history.at(-1);
|
||||
if (!previous) return;
|
||||
@@ -325,10 +502,16 @@ export function Workbench() {
|
||||
>
|
||||
Undo edit
|
||||
</button>
|
||||
{busy ? (
|
||||
<button type="button" onClick={() => cancelWorker()}>
|
||||
Cancel job
|
||||
</button>
|
||||
) : null}
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
aria-label="Open MIDI file"
|
||||
accept=".mid,.midi,audio/midi,audio/x-midi"
|
||||
onChange={(event) => void openFile(event.target.files?.[0])}
|
||||
/>
|
||||
@@ -336,6 +519,11 @@ export function Workbench() {
|
||||
<p className="status" role="status" aria-live="polite">
|
||||
{status}
|
||||
</p>
|
||||
{progress ? (
|
||||
<label>
|
||||
{progress.stage} <progress value={progress.progress} max="1" />
|
||||
</label>
|
||||
) : null}
|
||||
{document.warnings.length > 0 && (
|
||||
<ul className="warning-list">
|
||||
{document.warnings.map((warning) => (
|
||||
@@ -365,7 +553,101 @@ export function Workbench() {
|
||||
<Stat label="Duration" value={formatTime(summary.durationSeconds)} />
|
||||
<Stat label="Ticks" value={summary.durationTicks.toLocaleString()} />
|
||||
</dl>
|
||||
<PianoRoll notes={notes} durationTicks={summary.durationTicks} />
|
||||
<PianoRoll
|
||||
notes={notes}
|
||||
durationTicks={summary.durationTicks}
|
||||
selectedOrder={selectedNoteOrder}
|
||||
onSelect={selectNote}
|
||||
/>
|
||||
<div className="note-editor" aria-label="Selected note editor">
|
||||
<div>
|
||||
<h3>Note editor</h3>
|
||||
<p className="muted">
|
||||
Select a note in the piano roll to edit its paired note-on and
|
||||
note-off events.
|
||||
</p>
|
||||
</div>
|
||||
{selectedNote ? (
|
||||
<>
|
||||
<label>
|
||||
Pitch (0–127)
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={notePitch}
|
||||
onChange={(event) => setNotePitch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Velocity
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={noteVelocity}
|
||||
onChange={(event) => setNoteVelocity(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Start tick
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={noteStart}
|
||||
onChange={(event) => setNoteStart(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
End tick
|
||||
<input
|
||||
inputMode="numeric"
|
||||
value={noteEnd}
|
||||
onChange={(event) => setNoteEnd(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="button-row compact">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void runOperation(
|
||||
{
|
||||
kind: "edit-note",
|
||||
onOrder: selectedNote.onOrder,
|
||||
note: Number(notePitch),
|
||||
velocity: Number(noteVelocity),
|
||||
startTick: Number(noteStart),
|
||||
endTick: Number(noteEnd),
|
||||
},
|
||||
"Selected note updated.",
|
||||
)
|
||||
}
|
||||
>
|
||||
Apply note edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
void runOperation(
|
||||
{
|
||||
kind: "delete-note",
|
||||
onOrder: selectedNote.onOrder,
|
||||
},
|
||||
"Selected note removed.",
|
||||
)
|
||||
}
|
||||
>
|
||||
Delete note
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={notes.length === 0}
|
||||
onClick={() => notes[0] && selectNote(notes[0])}
|
||||
>
|
||||
Select first note
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="timeline-cards">
|
||||
<Timeline
|
||||
title="Tempo"
|
||||
@@ -380,6 +662,97 @@ export function Workbench() {
|
||||
<PitchKeyboard notes={notes} />
|
||||
</section>
|
||||
|
||||
<section className="panel" aria-labelledby="device-title">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Explicit opt-in · no SysEx</p>
|
||||
<h2 id="device-title">Web MIDI and local SoundFonts</h2>
|
||||
</div>
|
||||
</div>
|
||||
<p className="muted">
|
||||
Device access is requested only when you press the button. MIDI is
|
||||
sent only to the chosen local output. SoundFonts are inspected
|
||||
locally; this release deliberately does not pretend to render SF2 or
|
||||
SF3 because no vetted local SoundFont synthesizer is bundled yet.
|
||||
</p>
|
||||
<div className="device-grid">
|
||||
<div>
|
||||
<h3>Hardware/software MIDI output</h3>
|
||||
<div className="button-row">
|
||||
<button type="button" onClick={() => void enableWebMidi()}>
|
||||
Enable Web MIDI
|
||||
</button>
|
||||
{midiAccess ? (
|
||||
<label>
|
||||
Output
|
||||
<select
|
||||
value={midiOutputId}
|
||||
onChange={(event) => setMidiOutputId(event.target.value)}
|
||||
>
|
||||
<option value="">Choose an output</option>
|
||||
{midiOutputs.map((output) => (
|
||||
<option value={output.id} key={output.id}>
|
||||
{output.name ?? output.id} ({output.state})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={!midiAccess || !midiOutputId || isPlaying}
|
||||
onClick={() => void playWebMidi()}
|
||||
>
|
||||
Play to MIDI output
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>SoundFont inspection</h3>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => soundFontRef.current?.click()}
|
||||
>
|
||||
Inspect local SF2/SF3
|
||||
</button>
|
||||
<input
|
||||
ref={soundFontRef}
|
||||
className="sr-only"
|
||||
type="file"
|
||||
aria-label="Open SoundFont file"
|
||||
accept=".sf2,.sf3,audio/x-soundfont"
|
||||
onChange={(event) =>
|
||||
void openSoundFont(event.target.files?.[0])
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{soundFont ? (
|
||||
<dl className="soundfont-summary">
|
||||
<div>
|
||||
<dt>Bank</dt>
|
||||
<dd>{soundFont.bankName || soundFont.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Version</dt>
|
||||
<dd>{soundFont.version || "Not declared"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Presets</dt>
|
||||
<dd>{soundFont.presets ?? "Not declared"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Instruments / samples</dt>
|
||||
<dd>
|
||||
{soundFont.instruments ?? "?"} / {soundFont.samples ?? "?"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="operation-grid">
|
||||
<Operation title="Transpose">
|
||||
<label>
|
||||
@@ -392,9 +765,10 @@ export function Workbench() {
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runOperation(
|
||||
() => transposeMidi(document, Number(transpose)),
|
||||
{ kind: "transpose", semitones: Number(transpose) },
|
||||
"Pitch events transposed.",
|
||||
)
|
||||
}
|
||||
@@ -413,9 +787,10 @@ export function Workbench() {
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runOperation(
|
||||
() => quantizeMidi(document, Number(grid)),
|
||||
{ kind: "quantize", grid: Number(grid) },
|
||||
"Note boundaries quantized.",
|
||||
)
|
||||
}
|
||||
@@ -434,9 +809,10 @@ export function Workbench() {
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runOperation(
|
||||
() => constantTempoMidi(document, Number(bpm)),
|
||||
{ kind: "tempo", bpm: Number(bpm) },
|
||||
"Tempo map replaced with one value.",
|
||||
)
|
||||
}
|
||||
@@ -465,9 +841,14 @@ export function Workbench() {
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runOperation(
|
||||
() => cropMidi(document, Number(cropStart), Number(cropEnd)),
|
||||
{
|
||||
kind: "crop",
|
||||
start: Number(cropStart),
|
||||
end: Number(cropEnd),
|
||||
},
|
||||
"Timeline cropped and shifted to tick zero.",
|
||||
)
|
||||
}
|
||||
@@ -503,14 +884,14 @@ export function Workbench() {
|
||||
<div className="button-row compact">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runOperation(
|
||||
() =>
|
||||
remapChannel(
|
||||
document,
|
||||
Number(channelFrom),
|
||||
Number(channelTo),
|
||||
),
|
||||
{
|
||||
kind: "remap-channel",
|
||||
from: Number(channelFrom),
|
||||
to: Number(channelTo),
|
||||
},
|
||||
"Channel remapped.",
|
||||
)
|
||||
}
|
||||
@@ -519,9 +900,10 @@ export function Workbench() {
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runOperation(
|
||||
() => dropChannel(document, Number(channelFrom)),
|
||||
{ kind: "drop-channel", channel: Number(channelFrom) },
|
||||
"Channel events removed.",
|
||||
)
|
||||
}
|
||||
@@ -621,38 +1003,22 @@ export function Workbench() {
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([new Uint8Array(encodeMidi(document))], {
|
||||
type: "audio/midi",
|
||||
}),
|
||||
safeBase(document.name) + ".mid",
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
onClick={() => void exportDocument("midi")}
|
||||
>
|
||||
Download MIDI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([exportMidiCsv(document)], { type: "text/csv" }),
|
||||
safeBase(document.name) + ".csv",
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
onClick={() => void exportDocument("csv")}
|
||||
>
|
||||
Download CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
download(
|
||||
new Blob([exportMidiJson(document)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
safeBase(document.name) + ".json",
|
||||
)
|
||||
}
|
||||
disabled={busy}
|
||||
onClick={() => void exportDocument("json")}
|
||||
>
|
||||
Download JSON
|
||||
</button>
|
||||
@@ -661,12 +1027,30 @@ export function Workbench() {
|
||||
</main>
|
||||
);
|
||||
|
||||
function runOperation(operation: () => MidiDocument, message: string): void {
|
||||
try {
|
||||
commit(operation(), message);
|
||||
} catch (caught) {
|
||||
setStatus(caught instanceof Error ? caught.message : String(caught));
|
||||
async function runOperation(
|
||||
operation: MidiOperation,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const result = await executeTask({
|
||||
kind: "operation",
|
||||
document,
|
||||
operation,
|
||||
});
|
||||
if (result?.kind === "document") commit(result.document, message);
|
||||
}
|
||||
|
||||
async function exportDocument(
|
||||
format: "midi" | "csv" | "json",
|
||||
): Promise<void> {
|
||||
const result = await executeTask({ kind: "export", document, format });
|
||||
if (!result || result.kind === "document") return;
|
||||
const extension = format === "midi" ? "mid" : format;
|
||||
const blob =
|
||||
result.kind === "bytes"
|
||||
? new Blob([result.bytes], { type: result.mimeType })
|
||||
: new Blob([result.text], { type: result.mimeType });
|
||||
download(blob, safeBase(document.name) + "." + extension);
|
||||
setStatus(`${format.toUpperCase()} export generated in the worker.`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -774,3 +1158,8 @@ function safeBase(name: string): string {
|
||||
function download(blob: Blob, name: string): void {
|
||||
triggerBlobDownload(blob, name);
|
||||
}
|
||||
|
||||
function clearMidiQueue(output: MIDIOutput): void {
|
||||
const clear = (output as MIDIOutput & { clear?: () => void }).clear;
|
||||
clear?.call(output);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
constantTempoMidi,
|
||||
cropMidi,
|
||||
deleteNote,
|
||||
editNote,
|
||||
dropChannel,
|
||||
encodeMidi,
|
||||
exportMidiCsv,
|
||||
exportMidiJson,
|
||||
parseMidi,
|
||||
quantizeMidi,
|
||||
remapChannel,
|
||||
transposeMidi,
|
||||
type MidiDocument,
|
||||
} from "./midi";
|
||||
|
||||
export type MidiOperation =
|
||||
| { kind: "transpose"; semitones: number }
|
||||
| { kind: "quantize"; grid: number }
|
||||
| { kind: "tempo"; bpm: number }
|
||||
| { kind: "crop"; start: number; end: number }
|
||||
| { kind: "remap-channel"; from: number; to: number }
|
||||
| { kind: "drop-channel"; channel: number }
|
||||
| {
|
||||
kind: "edit-note";
|
||||
onOrder: number;
|
||||
note: number;
|
||||
velocity: number;
|
||||
startTick: number;
|
||||
endTick: number;
|
||||
}
|
||||
| { kind: "delete-note"; onOrder: number };
|
||||
|
||||
export type MidiTask =
|
||||
| { kind: "parse"; name: string; bytes: ArrayBuffer }
|
||||
| { kind: "operation"; document: MidiDocument; operation: MidiOperation }
|
||||
| {
|
||||
kind: "export";
|
||||
document: MidiDocument;
|
||||
format: "midi" | "csv" | "json";
|
||||
};
|
||||
|
||||
export type MidiTaskResult =
|
||||
| { kind: "document"; document: MidiDocument }
|
||||
| { kind: "bytes"; bytes: ArrayBuffer; mimeType: string }
|
||||
| { kind: "text"; text: string; mimeType: string };
|
||||
|
||||
/** Pure task entry point shared by the module worker and test fallback. */
|
||||
export function executeMidiTask(task: MidiTask): MidiTaskResult {
|
||||
if (task.kind === "parse")
|
||||
return {
|
||||
kind: "document",
|
||||
document: parseMidi(task.name, new Uint8Array(task.bytes)),
|
||||
};
|
||||
if (task.kind === "operation") {
|
||||
const { document, operation } = task;
|
||||
const next =
|
||||
operation.kind === "transpose"
|
||||
? transposeMidi(document, operation.semitones)
|
||||
: operation.kind === "quantize"
|
||||
? quantizeMidi(document, operation.grid)
|
||||
: operation.kind === "tempo"
|
||||
? constantTempoMidi(document, operation.bpm)
|
||||
: operation.kind === "crop"
|
||||
? cropMidi(document, operation.start, operation.end)
|
||||
: operation.kind === "remap-channel"
|
||||
? remapChannel(document, operation.from, operation.to)
|
||||
: operation.kind === "drop-channel"
|
||||
? dropChannel(document, operation.channel)
|
||||
: operation.kind === "edit-note"
|
||||
? editNote(document, operation.onOrder, operation)
|
||||
: deleteNote(document, operation.onOrder);
|
||||
return { kind: "document", document: next };
|
||||
}
|
||||
if (task.format === "midi") {
|
||||
const bytes = encodeMidi(task.document);
|
||||
return {
|
||||
kind: "bytes",
|
||||
bytes: Uint8Array.from(bytes).buffer,
|
||||
mimeType: "audio/midi",
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: "text",
|
||||
text:
|
||||
task.format === "csv"
|
||||
? exportMidiCsv(task.document)
|
||||
: exportMidiJson(task.document),
|
||||
mimeType: task.format === "csv" ? "text/csv" : "application/json",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
executeMidiTask,
|
||||
type MidiTask,
|
||||
type MidiTaskResult,
|
||||
} from "./midi-task";
|
||||
|
||||
export interface MidiWorkerProgress {
|
||||
progress: number;
|
||||
stage: string;
|
||||
}
|
||||
|
||||
export interface MidiWorkerJob {
|
||||
promise: Promise<MidiTaskResult>;
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
export function runMidiWorker(
|
||||
task: MidiTask,
|
||||
onProgress?: (update: MidiWorkerProgress) => void,
|
||||
): MidiWorkerJob {
|
||||
const id = ++nextId;
|
||||
if (typeof Worker === "undefined") {
|
||||
let cancelled = false;
|
||||
return {
|
||||
cancel: () => {
|
||||
cancelled = true;
|
||||
},
|
||||
promise: Promise.resolve().then(() => {
|
||||
if (cancelled) throw abortError();
|
||||
onProgress?.({ progress: 0.1, stage: "Processing MIDI" });
|
||||
const result = executeMidiTask(task);
|
||||
if (cancelled) throw abortError();
|
||||
onProgress?.({ progress: 1, stage: "Complete" });
|
||||
return result;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const worker = new Worker(new URL("./midi.worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
let settled = false;
|
||||
let rejectPromise: (reason: unknown) => void = () => undefined;
|
||||
const promise = new Promise<MidiTaskResult>((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
worker.onmessage = (
|
||||
event: MessageEvent<{
|
||||
id: number;
|
||||
kind: "progress" | "result" | "error";
|
||||
progress?: number;
|
||||
stage?: string;
|
||||
result?: MidiTaskResult;
|
||||
error?: string;
|
||||
}>,
|
||||
) => {
|
||||
if (event.data.id !== id || settled) return;
|
||||
if (event.data.kind === "progress") {
|
||||
onProgress?.({
|
||||
progress: event.data.progress ?? 0,
|
||||
stage: event.data.stage ?? "Processing MIDI",
|
||||
});
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
if (event.data.kind === "error")
|
||||
reject(new Error(event.data.error ?? "MIDI worker failed."));
|
||||
else if (event.data.result) resolve(event.data.result);
|
||||
else reject(new Error("MIDI worker returned no result."));
|
||||
};
|
||||
worker.onerror = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
reject(new Error("MIDI worker failed to start."));
|
||||
};
|
||||
const transfers = task.kind === "parse" ? [task.bytes] : [];
|
||||
worker.postMessage({ id, task }, transfers);
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
rejectPromise(abortError());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function abortError(): DOMException {
|
||||
return new DOMException("MIDI job cancelled.", "AbortError");
|
||||
}
|
||||
@@ -54,6 +54,13 @@ export interface NoteSpan {
|
||||
offOrder?: number;
|
||||
}
|
||||
|
||||
export interface NoteEdit {
|
||||
note: number;
|
||||
velocity: number;
|
||||
startTick: number;
|
||||
endTick: number;
|
||||
}
|
||||
|
||||
export interface TempoPoint {
|
||||
tick: number;
|
||||
microsecondsPerQuarter: number;
|
||||
@@ -531,6 +538,70 @@ export function cloneMidi(document: MidiDocument): MidiDocument {
|
||||
};
|
||||
}
|
||||
|
||||
/** Edit one complete note pair by its stable note-on event order. */
|
||||
export function editNote(
|
||||
document: MidiDocument,
|
||||
onOrder: number,
|
||||
edit: NoteEdit,
|
||||
): MidiDocument {
|
||||
if (!Number.isSafeInteger(onOrder))
|
||||
throw new RangeError("The selected note identifier is invalid.");
|
||||
if (!Number.isInteger(edit.note) || edit.note < 0 || edit.note > 127)
|
||||
throw new RangeError("Note pitch must be a whole value from 0 to 127.");
|
||||
if (
|
||||
!Number.isInteger(edit.velocity) ||
|
||||
edit.velocity < 1 ||
|
||||
edit.velocity > 127
|
||||
)
|
||||
throw new RangeError("Note velocity must be a whole value from 1 to 127.");
|
||||
if (
|
||||
!Number.isSafeInteger(edit.startTick) ||
|
||||
!Number.isSafeInteger(edit.endTick) ||
|
||||
edit.startTick < 0 ||
|
||||
edit.endTick <= edit.startTick ||
|
||||
edit.endTick > MAX_TICK
|
||||
)
|
||||
throw new RangeError("A note must have an end tick after its start tick.");
|
||||
const span = noteSpans(document).find((note) => note.onOrder === onOrder);
|
||||
if (!span) throw new Error("The selected note no longer exists.");
|
||||
if (span.offOrder === undefined)
|
||||
throw new Error(
|
||||
"A dangling note cannot be edited until it has a note-off.",
|
||||
);
|
||||
const result = cloneMidi(document);
|
||||
const track = result.tracks[span.track];
|
||||
if (!track) throw new Error("The selected note track no longer exists.");
|
||||
const on = track.events.find((event) => event.order === span.onOrder);
|
||||
const off = track.events.find((event) => event.order === span.offOrder);
|
||||
if (!on || on.kind !== "channel" || !off || off.kind !== "channel")
|
||||
throw new Error("The selected note pair could not be resolved.");
|
||||
on.tick = edit.startTick;
|
||||
on.data[0] = edit.note;
|
||||
on.data[1] = edit.velocity;
|
||||
off.tick = edit.endTick;
|
||||
off.data[0] = edit.note;
|
||||
track.events.sort(eventOrder);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Remove a note-on and its paired note-off without touching other events. */
|
||||
export function deleteNote(
|
||||
document: MidiDocument,
|
||||
onOrder: number,
|
||||
): MidiDocument {
|
||||
const span = noteSpans(document).find((note) => note.onOrder === onOrder);
|
||||
if (!span) throw new Error("The selected note no longer exists.");
|
||||
const result = cloneMidi(document);
|
||||
const remove = new Set([
|
||||
span.onOrder,
|
||||
...(span.offOrder === undefined ? [] : [span.offOrder]),
|
||||
]);
|
||||
const track = result.tracks[span.track];
|
||||
if (!track) throw new Error("The selected note track no longer exists.");
|
||||
track.events = track.events.filter((event) => !remove.has(event.order));
|
||||
return result;
|
||||
}
|
||||
|
||||
export function transposeMidi(
|
||||
document: MidiDocument,
|
||||
semitones: number,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
import { executeMidiTask, type MidiTask } from "./midi-task";
|
||||
|
||||
interface Request {
|
||||
id: number;
|
||||
task: MidiTask;
|
||||
}
|
||||
|
||||
declare const self: DedicatedWorkerGlobalScope;
|
||||
|
||||
self.onmessage = (event: MessageEvent<Request>) => {
|
||||
const { id, task } = event.data;
|
||||
try {
|
||||
self.postMessage({
|
||||
id,
|
||||
kind: "progress",
|
||||
progress: 0.1,
|
||||
stage: stage(task),
|
||||
});
|
||||
const result = executeMidiTask(task);
|
||||
self.postMessage({ id, kind: "progress", progress: 1, stage: "Complete" });
|
||||
if (result.kind === "bytes")
|
||||
self.postMessage({ id, kind: "result", result }, [result.bytes]);
|
||||
else if (result.kind === "document")
|
||||
self.postMessage(
|
||||
{ id, kind: "result", result },
|
||||
documentTransfers(result.document),
|
||||
);
|
||||
else self.postMessage({ id, kind: "result", result });
|
||||
} catch (reason) {
|
||||
self.postMessage({
|
||||
id,
|
||||
kind: "error",
|
||||
error: reason instanceof Error ? reason.message : "MIDI worker failed.",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
function stage(task: MidiTask): string {
|
||||
if (task.kind === "parse") return "Parsing MIDI";
|
||||
if (task.kind === "export") return `Encoding ${task.format.toUpperCase()}`;
|
||||
return "Applying " + task.operation.kind.replaceAll("-", " ");
|
||||
}
|
||||
|
||||
function documentTransfers(
|
||||
document: import("./midi").MidiDocument,
|
||||
): Transferable[] {
|
||||
const buffers = new Set<ArrayBuffer>();
|
||||
for (const track of document.tracks)
|
||||
for (const event of track.events)
|
||||
if (event.data.buffer instanceof ArrayBuffer)
|
||||
buffers.add(event.data.buffer);
|
||||
return [...buffers];
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,107 @@
|
||||
export const MAX_SOUNDFONT_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
export interface SoundFontSummary {
|
||||
name: string;
|
||||
size: number;
|
||||
bankName?: string;
|
||||
version?: string;
|
||||
presets?: number;
|
||||
instruments?: number;
|
||||
samples?: number;
|
||||
sections: string[];
|
||||
}
|
||||
|
||||
/** Inspect RIFF SoundFont structure locally. This does not synthesize audio. */
|
||||
export function inspectSoundFont(
|
||||
name: string,
|
||||
source: Uint8Array,
|
||||
): SoundFontSummary {
|
||||
if (source.length > MAX_SOUNDFONT_BYTES)
|
||||
throw new RangeError("SoundFont inspection is limited to 128 MiB.");
|
||||
if (source.length < 12 || ascii(source, 0, 4) !== "RIFF")
|
||||
throw new SyntaxError("The file is not a RIFF SoundFont.");
|
||||
if (ascii(source, 8, 4) !== "sfbk")
|
||||
throw new SyntaxError("The RIFF form type is not sfbk.");
|
||||
const declared = u32(source, 4) + 8;
|
||||
if (declared > source.length)
|
||||
throw new SyntaxError("The SoundFont RIFF container is truncated.");
|
||||
const summary: SoundFontSummary = { name, size: source.length, sections: [] };
|
||||
let offset = 12;
|
||||
let chunks = 0;
|
||||
while (offset + 8 <= Math.min(declared, source.length)) {
|
||||
if (++chunks > 10_000)
|
||||
throw new RangeError("The SoundFont contains too many RIFF chunks.");
|
||||
const id = ascii(source, offset, 4);
|
||||
const size = u32(source, offset + 4);
|
||||
const end = offset + 8 + size;
|
||||
if (end > declared || end > source.length)
|
||||
throw new SyntaxError("A SoundFont RIFF chunk is truncated.");
|
||||
if (id === "LIST" && size >= 4) {
|
||||
const type = ascii(source, offset + 8, 4);
|
||||
summary.sections.push(type);
|
||||
inspectList(source, offset + 12, end, type, summary);
|
||||
}
|
||||
offset = end + (size & 1);
|
||||
}
|
||||
if (!summary.sections.includes("pdta"))
|
||||
throw new SyntaxError("The SoundFont has no preset-data (pdta) section.");
|
||||
return summary;
|
||||
}
|
||||
|
||||
function inspectList(
|
||||
source: Uint8Array,
|
||||
start: number,
|
||||
end: number,
|
||||
type: string,
|
||||
summary: SoundFontSummary,
|
||||
): void {
|
||||
let offset = start;
|
||||
let chunks = 0;
|
||||
while (offset + 8 <= end) {
|
||||
if (++chunks > 10_000)
|
||||
throw new RangeError("A SoundFont list contains too many chunks.");
|
||||
const id = ascii(source, offset, 4);
|
||||
const size = u32(source, offset + 4);
|
||||
const dataStart = offset + 8;
|
||||
const dataEnd = dataStart + size;
|
||||
if (dataEnd > end)
|
||||
throw new SyntaxError("A nested SoundFont chunk is truncated.");
|
||||
if (type === "INFO" && id === "INAM")
|
||||
summary.bankName = cString(source, dataStart, dataEnd);
|
||||
if (type === "INFO" && id === "ifil" && size >= 4)
|
||||
summary.version = `${u16(source, dataStart)}.${u16(source, dataStart + 2)}`;
|
||||
if (type === "pdta" && id === "phdr" && size % 38 === 0)
|
||||
summary.presets = Math.max(0, size / 38 - 1);
|
||||
if (type === "pdta" && id === "inst" && size % 22 === 0)
|
||||
summary.instruments = Math.max(0, size / 22 - 1);
|
||||
if (type === "pdta" && id === "shdr" && size % 46 === 0)
|
||||
summary.samples = Math.max(0, size / 46 - 1);
|
||||
offset = dataEnd + (size & 1);
|
||||
}
|
||||
}
|
||||
|
||||
function ascii(source: Uint8Array, offset: number, length: number): string {
|
||||
return String.fromCharCode(...source.subarray(offset, offset + length));
|
||||
}
|
||||
|
||||
function cString(source: Uint8Array, start: number, end: number): string {
|
||||
const bytes = source.subarray(start, end);
|
||||
const nul = bytes.indexOf(0);
|
||||
return new TextDecoder()
|
||||
.decode(nul < 0 ? bytes : bytes.subarray(0, nul))
|
||||
.trim();
|
||||
}
|
||||
|
||||
function u16(source: Uint8Array, offset: number): number {
|
||||
return (source[offset] ?? 0) | ((source[offset + 1] ?? 0) << 8);
|
||||
}
|
||||
|
||||
function u32(source: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((source[offset] ?? 0) |
|
||||
((source[offset + 1] ?? 0) << 8) |
|
||||
((source[offset + 2] ?? 0) << 16) |
|
||||
((source[offset + 3] ?? 0) << 24)) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { playbackNotes, type MidiDocument } from "./midi";
|
||||
|
||||
export const MAX_WEB_MIDI_NOTES = 2_000;
|
||||
export const MAX_WEB_MIDI_SECONDS = 900;
|
||||
|
||||
export interface ScheduledMidiMessage {
|
||||
offsetMs: number;
|
||||
data: [number, number, number];
|
||||
}
|
||||
|
||||
/** Build a bounded, channel-only schedule. SysEx is never forwarded. */
|
||||
export function webMidiSchedule(
|
||||
document: MidiDocument,
|
||||
): ScheduledMidiMessage[] {
|
||||
const notes = playbackNotes(document).filter(
|
||||
(note) =>
|
||||
note.endSeconds > note.startSeconds &&
|
||||
note.startSeconds < MAX_WEB_MIDI_SECONDS,
|
||||
);
|
||||
if (notes.length === 0)
|
||||
throw new Error("There are no complete notes to play.");
|
||||
if (notes.length > MAX_WEB_MIDI_NOTES)
|
||||
throw new RangeError(
|
||||
"Web MIDI playback is capped at 2,000 notes; crop the file first.",
|
||||
);
|
||||
return notes
|
||||
.flatMap((note): ScheduledMidiMessage[] => [
|
||||
{
|
||||
offsetMs: Math.max(0, note.startSeconds * 1_000),
|
||||
data: [0x90 | note.channel, note.note, note.velocity],
|
||||
},
|
||||
{
|
||||
offsetMs:
|
||||
Math.min(
|
||||
MAX_WEB_MIDI_SECONDS,
|
||||
note.endSeconds,
|
||||
note.startSeconds + 30,
|
||||
) * 1_000,
|
||||
data: [0x80 | note.channel, note.note, 0],
|
||||
},
|
||||
])
|
||||
.sort((a, b) => a.offsetMs - b.offsetMs || a.data[0] - b.data[0]);
|
||||
}
|
||||
|
||||
export function allNotesOffMessages(): Array<[number, number, number]> {
|
||||
return Array.from({ length: 16 }, (_, channel): [number, number, number] => [
|
||||
0xb0 | channel,
|
||||
123,
|
||||
0,
|
||||
]);
|
||||
}
|
||||
+71
-2
@@ -302,6 +302,18 @@ body {
|
||||
stroke: color-mix(in srgb, var(--toolbox-accent) 70%, #000);
|
||||
stroke-width: 0.6;
|
||||
}
|
||||
.piano-roll .roll-note[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
.piano-roll .roll-note.selected {
|
||||
fill: var(--toolbox-focus);
|
||||
stroke-width: 2.5;
|
||||
}
|
||||
.piano-roll .roll-note:focus-visible {
|
||||
outline: none;
|
||||
stroke: var(--toolbox-focus);
|
||||
stroke-width: 3;
|
||||
}
|
||||
.piano-roll text {
|
||||
fill: var(--toolbox-muted);
|
||||
font-size: 10px;
|
||||
@@ -365,6 +377,54 @@ body {
|
||||
font-size: 0.65rem;
|
||||
opacity: 0.72;
|
||||
}
|
||||
.note-editor,
|
||||
.device-grid {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
.note-editor {
|
||||
grid-template-columns:
|
||||
minmax(12rem, 1.4fr) repeat(4, minmax(7rem, 0.65fr))
|
||||
auto;
|
||||
align-items: end;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.device-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.device-grid > div {
|
||||
min-width: 0;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
}
|
||||
.soundfont-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem !important;
|
||||
}
|
||||
.soundfont-summary div {
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.45rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.soundfont-summary dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 760;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.soundfont-summary dd {
|
||||
margin: 0.2rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.operation-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
@@ -457,10 +517,17 @@ body {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.note-editor {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.note-editor > div:first-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (max-width: 52rem) {
|
||||
.operation-grid,
|
||||
.timeline-cards {
|
||||
.timeline-cards,
|
||||
.device-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-grid {
|
||||
@@ -480,7 +547,9 @@ body {
|
||||
}
|
||||
.operation-grid,
|
||||
.filter-grid,
|
||||
.stats-grid {
|
||||
.stats-grid,
|
||||
.note-editor,
|
||||
.soundfont-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.filter-grid label:last-child {
|
||||
|
||||
@@ -3,12 +3,19 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.midi-tools",
|
||||
"name": "MIDI Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Inspect, edit and play MIDI locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["audio", "media", "developer"],
|
||||
"tags": ["midi", "piano roll", "tempo", "sequencer", "web audio"],
|
||||
"tags": [
|
||||
"midi",
|
||||
"piano roll",
|
||||
"tempo",
|
||||
"sequencer",
|
||||
"web audio",
|
||||
"soundfont"
|
||||
],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
@@ -21,6 +28,21 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{ "mediaType": "audio/midi", "extensions": [".mid", ".midi"] },
|
||||
{ "mediaType": "audio/x-soundfont", "extensions": [".sf2", ".sf3"] }
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "audio/midi", "extensions": [".mid"] },
|
||||
{ "mediaType": "text/csv", "extensions": [".csv"] },
|
||||
{ "mediaType": "application/json", "extensions": [".json"] }
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": ["workers", "web-audio", "web-midi"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": true,
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
for (const path of ["/", "/deep/nested/midi/"]) {
|
||||
test(`inspects and edits MIDI locally at ${path}`, async ({ page }) => {
|
||||
@@ -26,6 +27,40 @@ for (const path of ["/", "/deep/nested/midi/"]) {
|
||||
});
|
||||
}
|
||||
|
||||
test("parses and exports through the MIDI worker", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByLabel("Open MIDI file").setInputFiles({
|
||||
name: "worker.mid",
|
||||
mimeType: "audio/midi",
|
||||
buffer: midiFixture(),
|
||||
});
|
||||
await expect(page.getByRole("status")).toContainText("Parsed type 0 MIDI");
|
||||
const pending = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "Download MIDI" }).click();
|
||||
expect((await pending).suggestedFilename()).toBe("worker.mid");
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"MIDI export generated in the worker",
|
||||
);
|
||||
});
|
||||
|
||||
test("edits a paired note and inspects a local SoundFont", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByRole("button", { name: "Select first note" }).click();
|
||||
await page.getByLabel("Pitch (0–127)").fill("61");
|
||||
await page.getByLabel("Velocity").fill("77");
|
||||
await page.getByRole("button", { name: "Apply note edit" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("note updated");
|
||||
await page.getByLabel("Open SoundFont file").setInputFiles({
|
||||
name: "fixture.sf2",
|
||||
mimeType: "audio/x-soundfont",
|
||||
buffer: soundFontFixture(),
|
||||
});
|
||||
await expect(page.getByRole("status")).toContainText(
|
||||
"Inspected the local SoundFont structure",
|
||||
);
|
||||
await expect(page.getByText("Fixture bank", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("keeps the installed MIDI application available offline", async ({
|
||||
page,
|
||||
context,
|
||||
@@ -45,3 +80,31 @@ test("keeps the installed MIDI application available offline", async ({
|
||||
await context.setOffline(false);
|
||||
}
|
||||
});
|
||||
|
||||
function midiFixture(): Buffer {
|
||||
return Buffer.from([
|
||||
0x4d, 0x54, 0x68, 0x64, 0, 0, 0, 6, 0, 0, 0, 1, 1, 0xe0, 0x4d, 0x54, 0x72,
|
||||
0x6b, 0, 0, 0, 12, 0, 0x90, 60, 100, 0x83, 0x60, 60, 0, 0, 0xff, 0x2f, 0,
|
||||
]);
|
||||
}
|
||||
|
||||
function soundFontFixture(): Buffer {
|
||||
const chunk = (id: string, data: Buffer) => {
|
||||
const output = Buffer.alloc(8 + data.length + (data.length & 1));
|
||||
output.write(id, 0, "ascii");
|
||||
output.writeUInt32LE(data.length, 4);
|
||||
data.copy(output, 8);
|
||||
return output;
|
||||
};
|
||||
const list = (id: string, ...children: Buffer[]) =>
|
||||
chunk("LIST", Buffer.concat([Buffer.from(id, "ascii"), ...children]));
|
||||
const body = Buffer.concat([
|
||||
Buffer.from("sfbk", "ascii"),
|
||||
list("INFO", chunk("INAM", Buffer.from("Fixture bank\0"))),
|
||||
list("pdta", chunk("phdr", Buffer.alloc(76))),
|
||||
]);
|
||||
const output = Buffer.alloc(8);
|
||||
output.write("RIFF", 0, "ascii");
|
||||
output.writeUInt32LE(body.length, 4);
|
||||
return Buffer.concat([output, body]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/midi/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -49,7 +49,9 @@ describe("MIDI Workbench", () => {
|
||||
await user.clear(screen.getByLabelText("Semitones"));
|
||||
await user.type(screen.getByLabelText("Semitones"), "12");
|
||||
await user.click(screen.getByRole("button", { name: "Apply transpose" }));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("transposed");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("status")).toHaveTextContent("transposed"),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Undo edit" }));
|
||||
expect(screen.getByRole("status")).toHaveTextContent("undone");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectSoundFont } from "../../src/core/soundfont";
|
||||
import { allNotesOffMessages, webMidiSchedule } from "../../src/core/web-midi";
|
||||
import type { MidiDocument } from "../../src/core/midi";
|
||||
|
||||
const midi: MidiDocument = {
|
||||
name: "local.mid",
|
||||
format: 0,
|
||||
division: 480,
|
||||
warnings: [],
|
||||
tracks: [
|
||||
{
|
||||
name: "Notes",
|
||||
events: [
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 0,
|
||||
order: 0,
|
||||
status: 9,
|
||||
channel: 2,
|
||||
data: Uint8Array.of(64, 99),
|
||||
},
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 480,
|
||||
order: 1,
|
||||
status: 8,
|
||||
channel: 2,
|
||||
data: Uint8Array.of(64, 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("explicit local playback helpers", () => {
|
||||
it("creates channel-only Web MIDI schedules and panic messages", () => {
|
||||
expect(webMidiSchedule(midi)).toEqual([
|
||||
{ offsetMs: 0, data: [0x92, 64, 99] },
|
||||
{ offsetMs: 500, data: [0x82, 64, 0] },
|
||||
]);
|
||||
expect(allNotesOffMessages()).toHaveLength(16);
|
||||
expect(allNotesOffMessages()[2]).toEqual([0xb2, 123, 0]);
|
||||
});
|
||||
|
||||
it("inspects a bounded RIFF SoundFont without synthesizing it", () => {
|
||||
const info = list("INFO", chunk("INAM", text("Fixture bank\0")));
|
||||
const phdr = chunk("phdr", new Uint8Array(38 * 2));
|
||||
const pdta = list("pdta", phdr);
|
||||
const body = bytes(text("sfbk"), info, pdta);
|
||||
const fixture = bytes(text("RIFF"), le32(body.length), body);
|
||||
expect(inspectSoundFont("fixture.sf2", fixture)).toMatchObject({
|
||||
bankName: "Fixture bank",
|
||||
presets: 1,
|
||||
sections: ["INFO", "pdta"],
|
||||
});
|
||||
fixture[8] = 0;
|
||||
expect(() => inspectSoundFont("bad.sf2", fixture)).toThrow(/sfbk/u);
|
||||
});
|
||||
});
|
||||
|
||||
function list(type: string, ...children: Uint8Array[]): Uint8Array {
|
||||
return chunk("LIST", bytes(text(type), ...children));
|
||||
}
|
||||
|
||||
function chunk(id: string, data: Uint8Array): Uint8Array {
|
||||
return bytes(
|
||||
text(id),
|
||||
le32(data.length),
|
||||
data,
|
||||
...(data.length & 1 ? [Uint8Array.of(0)] : []),
|
||||
);
|
||||
}
|
||||
|
||||
function bytes(...parts: Uint8Array[]): Uint8Array {
|
||||
const output = new Uint8Array(
|
||||
parts.reduce((sum, part) => sum + part.length, 0),
|
||||
);
|
||||
let offset = 0;
|
||||
for (const part of parts) {
|
||||
output.set(part, offset);
|
||||
offset += part.length;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function text(value: string): Uint8Array {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
|
||||
function le32(value: number): Uint8Array {
|
||||
return Uint8Array.of(value, value >>> 8, value >>> 16, value >>> 24);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { executeMidiTask } from "../../src/core/midi-task";
|
||||
import { encodeMidi, noteSpans, type MidiDocument } from "../../src/core/midi";
|
||||
|
||||
const document: MidiDocument = {
|
||||
name: "worker.mid",
|
||||
format: 0,
|
||||
division: 480,
|
||||
warnings: [],
|
||||
tracks: [
|
||||
{
|
||||
name: "Notes",
|
||||
events: [
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 0,
|
||||
order: 0,
|
||||
status: 9,
|
||||
channel: 0,
|
||||
data: Uint8Array.of(60, 100),
|
||||
},
|
||||
{
|
||||
kind: "channel",
|
||||
tick: 480,
|
||||
order: 1,
|
||||
status: 8,
|
||||
channel: 0,
|
||||
data: Uint8Array.of(60, 0),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("MIDI worker task protocol", () => {
|
||||
it("parses, transforms and exports without UI state", () => {
|
||||
const encoded = encodeMidi(document);
|
||||
const parsed = executeMidiTask({
|
||||
kind: "parse",
|
||||
name: "worker.mid",
|
||||
bytes: Uint8Array.from(encoded).buffer,
|
||||
});
|
||||
expect(parsed.kind).toBe("document");
|
||||
if (parsed.kind !== "document") return;
|
||||
const transformed = executeMidiTask({
|
||||
kind: "operation",
|
||||
document: parsed.document,
|
||||
operation: { kind: "transpose", semitones: 12 },
|
||||
});
|
||||
expect(transformed.kind).toBe("document");
|
||||
if (transformed.kind !== "document") return;
|
||||
expect(noteSpans(transformed.document)[0]?.note).toBe(72);
|
||||
const edited = executeMidiTask({
|
||||
kind: "operation",
|
||||
document: transformed.document,
|
||||
operation: {
|
||||
kind: "edit-note",
|
||||
onOrder: noteSpans(transformed.document)[0]!.onOrder,
|
||||
note: 70,
|
||||
velocity: 80,
|
||||
startTick: 10,
|
||||
endTick: 240,
|
||||
},
|
||||
});
|
||||
expect(edited.kind).toBe("document");
|
||||
if (edited.kind !== "document") return;
|
||||
expect(noteSpans(edited.document)[0]).toMatchObject({
|
||||
note: 70,
|
||||
velocity: 80,
|
||||
startTick: 10,
|
||||
endTick: 240,
|
||||
});
|
||||
expect(
|
||||
executeMidiTask({
|
||||
kind: "export",
|
||||
document: edited.document,
|
||||
format: "csv",
|
||||
}).kind,
|
||||
).toBe("text");
|
||||
});
|
||||
});
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
constantTempoMidi,
|
||||
cropMidi,
|
||||
deleteNote,
|
||||
dropChannel,
|
||||
encodeMidi,
|
||||
editNote,
|
||||
exportMidiCsv,
|
||||
exportMidiJson,
|
||||
noteSpans,
|
||||
@@ -262,4 +264,31 @@ describe("MIDI editing operations", () => {
|
||||
/outside MIDI/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("edits and deletes one paired note without mutating its neighbours", () => {
|
||||
const source = documentFixture();
|
||||
const first = noteSpans(source)[0]!;
|
||||
const edited = editNote(source, first.onOrder, {
|
||||
note: 61,
|
||||
velocity: 77,
|
||||
startTick: 100,
|
||||
endTick: 700,
|
||||
});
|
||||
expect(noteSpans(edited)[0]).toMatchObject({
|
||||
note: 61,
|
||||
velocity: 77,
|
||||
startTick: 100,
|
||||
endTick: 700,
|
||||
});
|
||||
expect(noteSpans(source)[0]?.note).toBe(60);
|
||||
expect(noteSpans(deleteNote(edited, first.onOrder))).toHaveLength(1);
|
||||
expect(() =>
|
||||
editNote(source, first.onOrder, {
|
||||
note: 60,
|
||||
velocity: 100,
|
||||
startTick: 20,
|
||||
endTick: 20,
|
||||
}),
|
||||
).toThrow(/after its start/u);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "node"],
|
||||
"allowArbitraryExtensions": true,
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
|
||||
Reference in New Issue
Block a user