diff --git a/agent-notes/feat-sf2-synth.md b/agent-notes/feat-sf2-synth.md new file mode 100644 index 00000000..a56054ed --- /dev/null +++ b/agent-notes/feat-sf2-synth.md @@ -0,0 +1,99 @@ +# feat/sf2-synth + +## Goal +Replace the MIDI.js soundfont renderer (src/midi/SoundfontOutput.ts) with a +spessasynth_lib WorkletSynthesizer playing one bank, MuseScore_General.sf3, built by +scripts/build-soundbank.mjs from MuseScore_General.sf2 (MIT). Remove the three +MIDI.js sets and the font picker. PR against master. + +## State +- package.json pins spessasynth_core 4.3.22, spessasynth_lib 4.3.14, stb-vorbis 0.0.6 + (npm `latest` on 2026-10-20; `npm ls` shows the nested "latest" deps deduped onto these). +- scripts/build-soundbank.mjs: second full run with the ruled length gate (quality 6, + 9m18s) printed "all gates passed", exit=0; 3 allowed longer decodes (79, 80, 599). + Outputs: MuseScore_General.flac.sf3 95,679,054 B, MuseScore_General.sf3 32,986,146 B, + MuseScore_General_License.md 3,419 B, in the session scratchpad bank/ (never the repo). + Not uploaded. +- Committed f0a90314; merged origin/master (f955956e, #202 auto-midi). The one conflict + (preferencesStore.test.ts) resolved by keeping master's three MIDI-default tests and + dropping the soundfont test; master's STORAGE_VERSION 3, enabled default and migration kept. +- Client: SoundfontOutput.test.ts rewritten (red: 8/8 failed on the old renderer); + SoundfontOutput.ts rewritten on WorkletSynthesizer; soundfonts.ts cut to + SOUNDFONT_OUTPUT + SOUNDFONT_NAME. Green 8/8. Done: MidiService.ts, preferencesStore.ts + (+test), preferences.tsx, MidiStatus.tsx card (+css), programs.ts and + SoundfontPicker.{tsx,css} deleted, vite.config.ts globIgnores, check-soundfonts.mjs + (fixture = core sample bank; first download 503 -> "could not load", Reconnect -> "ready"), + docs/GMCP/packages/client-midi.md. Refactor pass done. + check-soundfonts.mjs was not run locally (CI runs it; rule: no local CI re-runs). + +## Decisions and why +- Coordinator ruling (option 1): this PR removes `midiJsSoundfont` from + preferencesStore.ts (:9 import, :67 field, :176 normalisation), its test + (preferencesStore.test.ts:243-247), and the picker in preferences.tsx (:11, :404). + It does not touch the `enabled` default, STORAGE_VERSION or `migrate` (feat/auto-midi owns those). + No migration: a leftover `midiJsSoundfont` key is ignored. +- FLAC master is written by core as an SF3-shaped file (compressed flag 0x10, version 3) + whose sample payloads are FLAC; named MuseScore_General.flac.sf3. Core's own loader + would try to Vorbis-decode them, so it is a master for rebuilding, not for playback. +- FLAC is encoded to a temp file, not a pipe, so ffmpeg can fill in STREAMINFO. + +- ready() resolves once the worklet module is added and the synth exists; the bank + downloads in the background and messages go to the built-in synth until it loads + (that is "fallback while the bank loads"). Program/CC/bend sent meanwhile are kept + (latest per channel/controller) and replayed into the synth on load; after the switch, + note-offs and CC 64/120/123 also go to the built-in synth so notes it started end. +- addSoundBank never resolves on a bad bank: the worklet posts `soundBankError` instead + (lib basic_synthesizer_core.ts:386-391, basic_synthesizer.ts:846-848), so load() also + listens for that event. +- The worklet file is a `?url` asset; vite.config.ts:31 precaches **/*.js and + optionalChunks.ts:33 only drops chunks, so it needs a globIgnores entry to stay out of + the service-worker install (402,596 bytes). +- CI runs scripts/check-soundfonts.mjs (build-test.yml:47 via test:audio-cache); it drove + the MIDI.js renderer and must be rewritten. Offline fixture: core's + BasicSoundBank.getSampleSoundBankFile() (a real one-preset SF2). + +## Surprises +- core basic_sample.ts:170-185: compressSample swallows encoder errors and leaves the + sample as PCM, so the script gates on `isCompressed` for every sample. +- core basic_sample.ts:173-176: samples under 8 kHz or over 96 kHz get resampled to + 48 kHz before encoding; the structure gate would catch that as a rate/loop mismatch. +- stb-vorbis index.ts:114,141: decode throws until `await StbVorbis.ready`. + +## Gate failure (stopped here, nothing committed or pushed) +First full run (quality 6, 8m31s, exit=1), using the script before the describe() +refactor (refactor only folded the duplicated modulator description; not re-run since): +- PASS every sample FLAC-encoded; FLAC master byte-identical 1246/1246; FLAC structure all match. +- PASS every sample Vorbis-encoded; SF3 structure all match (presets 309, generators 11854, + modulators 1769, loop points 1246). +- FAIL stb-vorbis decoded length: 1243/1246. Decoded vs source samples: + 79 Cello-G#1 37376 vs 37067; 80 Cello-F1 33920 vs 33493; 599 OcarE1 60544 vs 60416. + Cause not investigated. +- Sizes: MuseScore_General.flac.sf3 95,679,054 B; MuseScore_General.sf3 32,986,146 B. +- Line endings: src/MidiService.ts had mixed CRLF/LF at HEAD; the edit tool rewrote it + all-CRLF, so `git diff --stat` shows 161 lines where `--ignore-cr-at-eol` shows 9. + +## Ruling on the length gate (Q, via coordinator) +Cause per coordinator: ffmpeg's last granule equals the source length and ffmpeg decodes +to it; stb-vorbis 0.0.6's final-frame trim (wasm/stb_vorbis.c:3413-3443) fails. A longer +decode now passes only if (a) offset 0 gives the lowest rms error of all shifts in +[-64, 64] (my reading of "aligns at offset 0"; reported per sample), and (b) every +instrument zone using the sample has sampleModes&3 == 1 and generators 1/12/3/50 all 0 +(zone value, else the instrument global zone, else 0). Unused samples fail. Each allowed +exception is printed. + +## CI failure on #203 (head 681dd6d8, run 39534394351) +check-soundfonts.mjs:62 expected "could not load"; the bank loaded with no Reconnect. +Reproduced on a dev server (scratchpad debug/sf-double.mjs, fetch wrapped to log stacks): +one Connect click -> 2 bank requests; the 2nd comes from MidiStatus +updateReconnectableDevices -> connectOutputDevice via loadDevices. Temporary TRACE logs +(removed) pinned the caller: handleConnectOutput's `loadDevices()` after success +(MidiStatus.tsx:329) -> updateReconnectableDevices, whose check at :185 read the +handler render's `connectionState` (closure outputConnected=false, live=true) while +MidiService.ts:315 had just saved lastOutputDeviceId=soundfont-synth -> second connect (:194). +The old renderer hid it: its ready() threw on the failed first download, so nothing was saved. +Fix: updateReconnectableDevices reads live midiService.connectionStatus (input and output). +Test: MidiStatus.test.tsx "connects an output once per Connect..." red (2 calls) before the fix. + +## Next step +Commit, push to #203, read CI. Uploading the bank to mongoose.world is not part +of this task. diff --git a/docs/GMCP/packages/client-midi.md b/docs/GMCP/packages/client-midi.md index 5152883f..463cf3bd 100644 --- a/docs/GMCP/packages/client-midi.md +++ b/docs/GMCP/packages/client-midi.md @@ -2,14 +2,14 @@ ## Browser soundfonts and effects -Enable MIDI in Preferences, choose a soundfont collection, then connect -**Soundfont Synthesizer** in the MIDI tab. Musyng Kite, FluidR3, and FatBoy -download from [MIDI.js Soundfonts](https://github.com/gleitz/midi-js-soundfonts). -The renderer loads only when this output is connected; opening Preferences or -the MIDI tab does not download samples. Piano loads on connection. Other -programs load as requested, with the built-in synth providing immediate -playback while they load. Channel 10 percussion always uses the built-in synth. -Reconnect to apply a different collection or retry failed instruments. +Enable MIDI in Preferences, then connect **Soundfont Synthesizer** in the MIDI +tab. It is a SoundFont synthesizer ([spessasynth](https://github.com/spessasus/spessasynth_lib)) +playing one bank, MuseScore General (MIT), downloaded from +`https://mongoose.world/sounds/soundfonts/MuseScore_General.sf3`. That file is +built by `scripts/build-soundbank.mjs`. The synthesizer and the bank load only when +this output is connected; opening Preferences or the MIDI tab downloads nothing. +The built-in synth plays everything, percussion included, until the bank has +loaded, and stays in use if it cannot load. Reconnect to retry. Both browser synths share the client's volume, mute, Escape/Resume, and master effects. External hardware outputs are controlled by their own audio devices. @@ -37,11 +37,6 @@ recreating it restores the requested route. Chain presets, replacement, Server disconnect/reset clears the route. Route does not enable MIDI, load a soundfont, or alter external hardware outputs. -Sample memory is bounded: four encoded instruments (32 MiB total), 32 MiB of -decoded cache, four concurrent decodes, two instrument downloads, and 64 active -sample voices. Closest decoded samples are pitch-shifted while an exact note -decodes. Files are capped at 16 MiB and downloads time out after 20 seconds. - `Client.Midi` is a Mongoose bidirectional Web MIDI bridge implemented in `src/gmcp/Client/Midi.ts`. diff --git a/package-lock.json b/package-lock.json index 40cb391f..d54749d6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,6 +34,9 @@ "react-focus-lock": "^2.13.7", "react-icons": "^5.6.0", "react-use": "^17.6.0", + "spessasynth_core": "4.3.22", + "spessasynth_lib": "4.3.14", + "stb-vorbis": "0.0.6", "strip-ansi": "^7.2.0", "tree-sitter-moocode": "^0.4.0", "turndown": "^7.2.4", @@ -7837,6 +7840,24 @@ "dev": true, "license": "MIT" }, + "node_modules/spessasynth_core": { + "version": "4.3.22", + "resolved": "https://registry.npmjs.org/spessasynth_core/-/spessasynth_core-4.3.22.tgz", + "integrity": "sha512-qP6NkcZLmTLIs9fCUh2IhZRkd3Junwu8ppD4K0l/0R+Lo4P48d4Rbe8YQ8RlEYyRKFz8HcPM75sXYVQLAb7k4A==", + "license": "Apache-2.0", + "dependencies": { + "stb-vorbis": "latest" + } + }, + "node_modules/spessasynth_lib": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/spessasynth_lib/-/spessasynth_lib-4.3.14.tgz", + "integrity": "sha512-M3In44Erc0OPlbLwOrPoqnMiva0nbh7LCbGfNTi21JV+NMN/B4sun2d8HR17J/dSGp+PEQnJ0ZkujnWEHWfi4Q==", + "license": "Apache-2.0", + "dependencies": { + "spessasynth_core": "latest" + } + }, "node_modules/stack-generator": { "version": "2.0.10", "resolved": "https://registry.npmjs.org/stack-generator/-/stack-generator-2.0.10.tgz", @@ -7905,6 +7926,12 @@ "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", "license": "MIT" }, + "node_modules/stb-vorbis": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/stb-vorbis/-/stb-vorbis-0.0.6.tgz", + "integrity": "sha512-CyvjJEGROIdnuZknwFvdOfMIO1ssUxO2OawsXCR3IlbJ8z2IMBjDe8FDxFJqBfLgf1ZOdqvoHAYx3Xk8q9rbbg==", + "license": "Apache-2.0" + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", diff --git a/package.json b/package.json index 51c73d72..d7982e09 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,9 @@ "react-focus-lock": "^2.13.7", "react-icons": "^5.6.0", "react-use": "^17.6.0", + "spessasynth_core": "4.3.22", + "spessasynth_lib": "4.3.14", + "stb-vorbis": "0.0.6", "strip-ansi": "^7.2.0", "tree-sitter-moocode": "^0.4.0", "turndown": "^7.2.4", diff --git a/scripts/build-soundbank.mjs b/scripts/build-soundbank.mjs new file mode 100644 index 00000000..8eddb46f --- /dev/null +++ b/scripts/build-soundbank.mjs @@ -0,0 +1,362 @@ +#!/usr/bin/env node +// Builds the client's sound bank from MuseScore_General.sf2 (MIT). +// +// node scripts/build-soundbank.mjs [--source-dir ] [--quality ] +// +// Writes, into (never the repo): +// MuseScore_General.flac.sf3 the master: every sample FLAC-encoded, lossless +// MuseScore_General.sf3 the bank the client fetches: Vorbis at each sample's own rate +// MuseScore_General_License.md +// --source-dir reads MuseScore_General.sf2 and MuseScore_General_License.md from +// a directory instead of downloading them. Needs ffmpeg with libvorbis on PATH. +// Exits nonzero if any gate fails. +import { spawn } from 'node:child_process'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { SoundBankLoader, SpessaLog } from 'spessasynth_core'; +import { StbVorbis } from 'stb-vorbis'; + +const SOURCE_URL = 'https://ftp.osuosl.org/pub/musescore/soundfont/MuseScore_General/'; +const SOURCE = 'MuseScore_General.sf2'; +const LICENSE = 'MuseScore_General_License.md'; +const FLAC_OUT = 'MuseScore_General.flac.sf3'; +const VORBIS_OUT = 'MuseScore_General.sf3'; +const SEAM = 64; // samples either side of each loop point +const TOP = 20; + +const args = process.argv.slice(2); +const option = (name, fallback) => { + const i = args.indexOf(name); + return i < 0 ? fallback : args.splice(i, 2)[1]; +}; +const sourceDir = option('--source-dir'); +const quality = Number(option('--quality', '6')); +const outDir = args[0]; +if (!outDir) { + console.error('usage: node scripts/build-soundbank.mjs [--source-dir ] [--quality ]'); + process.exit(2); +} +SpessaLog.infoEnabled = false; + +function run(command, commandArgs, input) { + return new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { stdio: ['pipe', 'pipe', 'pipe'] }); + const out = []; + const err = []; + child.stdout.on('data', (chunk) => out.push(chunk)); + child.stderr.on('data', (chunk) => err.push(chunk)); + child.on('error', reject); + child.on('close', (code) => + code === 0 + ? resolve(Buffer.concat(out)) + : reject(new Error(`${command} exited ${code}: ${Buffer.concat(err).toString()}`)), + ); + child.stdin.on('error', () => {}); + child.stdin.end(input); + }); +} +const ffmpeg = (ffArgs, input) => run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', ...ffArgs], input); +const pcmIn = (rate) => ['-f', 's16le', '-ar', String(rate), '-ac', '1', '-i', 'pipe:0']; + +/** Core hands encoders float audio that it made from int16 / 32768, so this is exact. */ +function toS16(audio) { + const out = new Int16Array(audio.length); + for (let i = 0; i < audio.length; i++) { + const v = Math.round(audio[i] * 32768); + out[i] = v > 32767 ? 32767 : v < -32768 ? -32768 : v; + } + return new Uint8Array(out.buffer); +} + +async function fetchBytes(name) { + if (sourceDir) return readFile(path.join(sourceDir, name)); + const response = await fetch(SOURCE_URL + name); + if (!response.ok) throw new Error(`${SOURCE_URL + name}: HTTP ${response.status}`); + return Buffer.from(await response.arrayBuffer()); +} +const load = (bytes) => + SoundBankLoader.fromArrayBuffer(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)); + +/** One line per sample, instrument, preset, zone, generator and modulator, keyed by kind. */ +function describe(bank) { + const lines = []; + const add = (kind, key, value) => lines.push({ kind, key, value: JSON.stringify(value) }); + const modulators = (owner, list) => + list.forEach((m, i) => + add('modulators', `${owner} mod ${i}`, [ + m.primarySource.toSourceEnum(), + m.secondarySource.toSourceEnum(), + m.destination, + m.transformAmount, + m.transformType, + ]), + ); + const zone = (owner, z, target) => { + add('zones', owner, { key: z.keyRange, vel: z.velRange, target }); + z.generators.forEach((g, i) => add('generators', `${owner} gen ${i}`, [g.type, g.value])); + modulators(owner, z.modulators); + }; + modulators('default', bank.defaultModulators); + bank.samples.forEach((s, i) => { + const owner = `sample ${i} ${s.name}`; + add('samples', owner, { + rate: s.sampleRate, + key: s.originalKey, + cents: s.pitchCorrection, + type: s.sampleType, + link: s.linkedSample ? bank.samples.indexOf(s.linkedSample) : -1, + }); + add('loop points', owner, [s.loopStart, s.loopEnd]); + }); + bank.instruments.forEach((inst, i) => { + const owner = `instrument ${i} ${inst.name}`; + add('instruments', owner, { zones: inst.zones.length }); + zone(`${owner} global`, inst.globalZone, null); + inst.zones.forEach((z, j) => zone(`${owner} zone ${j}`, z, bank.samples.indexOf(z.sample))); + }); + bank.presets.forEach((p, i) => { + const owner = `preset ${i} ${p.name}`; + add('presets', owner, { + name: p.name, + program: p.program, + msb: p.bankMSB, + lsb: p.bankLSB, + drum: p.isGMGSDrum, + library: p.library, + genre: p.genre, + morphology: p.morphology, + zones: p.zones.length, + }); + zone(`${owner} global`, p.globalZone, null); + p.zones.forEach((z, j) => zone(`${owner} zone ${j}`, z, bank.instruments.indexOf(z.instrument))); + }); + return lines; +} + +/** Compares structure; returns the number of mismatches and prints a count per kind. */ +function compareStructure(label, source, output) { + const a = describe(source); + const b = describe(output); + const counts = {}; + const mismatches = []; + for (const line of a) counts[line.kind] = counts[line.kind] ?? { total: 0, bad: 0 }; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + const x = a[i]; + const y = b[i]; + if (x && y && x.kind === y.kind && x.key === y.key && x.value === y.value) { + counts[x.kind].total++; + continue; + } + const kind = x?.kind ?? y.kind; + counts[kind] = counts[kind] ?? { total: 0, bad: 0 }; + counts[kind].total++; + counts[kind].bad++; + mismatches.push(` ${x?.key ?? '(missing)'}: ${x?.value} != ${y?.key ?? '(missing)'} ${y?.value}`); + } + console.log(`${label} structure vs source:`); + for (const [kind, { total, bad }] of Object.entries(counts)) + console.log(` ${kind}: ${total - bad}/${total} match`); + for (const line of mismatches.slice(0, 20)) console.log(line); + if (mismatches.length > 20) console.log(` ... ${mismatches.length - 20} more`); + return mismatches.length; +} + +/** + * Whether every instrument zone playing `sample` loops without release (sampleModes 1) and + * moves no end or loop-end point, so playback stays inside the loop + * (core note_on.ts:349-350; only mode 3 leaves the loop, render_voice.ts:53-55). + * A zone's value falls back to its instrument's global zone, then the default 0. + */ +function loopOnlyModes(bank, sample) { + const zones = []; + let ok = true; + for (const inst of bank.instruments) + for (const z of inst.zones) { + if (z.sample !== sample) continue; + const value = (type) => z.getGenerator(type, inst.globalZone.getGenerator(type, 0)); + const mode = value(54) & 3; + const offsets = [1, 12, 3, 50].map(value); + if (mode !== 1 || offsets.some((v) => v !== 0)) ok = false; + zones.push(`${inst.name} mode ${mode} offsets ${offsets.join('/')}`); + } + return { ok: ok && zones.length > 0, zones }; +} + +function notCompressed(bank) { + return bank.samples.filter((s) => !s.isCompressed).map((s) => s.name); +} + +const dB = (x) => (x > 0 ? (20 * Math.log10(x)).toFixed(1) : '-inf'); + +async function main() { + const failures = []; + const gate = (name, ok, detail = '') => { + console.log(`GATE ${ok ? 'PASS' : 'FAIL'}: ${name}${detail ? ` (${detail})` : ''}`); + if (!ok) failures.push(name); + }; + + const sourceBytes = await fetchBytes(SOURCE); + const license = await fetchBytes(LICENSE); + const source = load(sourceBytes); + console.log( + `source: ${SOURCE} ${sourceBytes.length} bytes, ${source.presets.length} presets, ` + + `${source.instruments.length} instruments, ${source.samples.length} samples`, + ); + const sourcePcm = source.samples.map((s) => s.getRawData(false)); + + const scratch = await mkdtemp(path.join(tmpdir(), 'soundbank-')); + try { + // (a) FLAC master. ffmpeg writes FLAC to a file so it can seek back and fill + // in STREAMINFO (length and MD5); a pipe would leave them empty. + let n = 0; + const flacBank = load(sourceBytes); + await flacBank.setSampleFormat({ + format: 'compressed', + compressionFunction: async (audio, rate) => { + const file = path.join(scratch, `${n++}.flac`); + await ffmpeg([...pcmIn(rate), '-c:a', 'flac', '-compression_level', '8', file], toS16(audio)); + return new Uint8Array(await readFile(file)); + }, + }); + const flacUncompressed = notCompressed(flacBank); + gate('every sample FLAC-encoded', flacUncompressed.length === 0, flacUncompressed.slice(0, 5).join(', ')); + await writeFile(path.join(outDir, FLAC_OUT), new Uint8Array(flacBank.writeSF2())); + + const flac = load(await readFile(path.join(outDir, FLAC_OUT))); + let flacBad = 0; + for (const [i, s] of flac.samples.entries()) { + const pcm = await ffmpeg(['-i', 'pipe:0', '-f', 's16le', '-ac', '1', 'pipe:1'], s.getRawData(true)); + if (!s.isCompressed || Buffer.compare(pcm, Buffer.from(sourcePcm[i])) !== 0) { + flacBad++; + if (flacBad <= 5) + console.log(` FLAC mismatch: sample ${i} ${s.name}: ${pcm.length} vs ${sourcePcm[i].length} bytes`); + } + } + gate( + 'FLAC master decodes byte-identical to source PCM', + flacBad === 0 && flac.samples.length === source.samples.length, + `${flac.samples.length - flacBad}/${source.samples.length} samples identical`, + ); + gate('FLAC master structure matches source', compareStructure('FLAC master', source, flac) === 0); + + // (b) Vorbis SF3 at each sample's own rate. + const vorbisBank = load(sourceBytes); + await vorbisBank.setSampleFormat({ + format: 'compressed', + compressionFunction: async (audio, rate) => + new Uint8Array( + await ffmpeg([...pcmIn(rate), '-c:a', 'libvorbis', '-q:a', String(quality), '-f', 'ogg', 'pipe:1'], toS16(audio)), + ), + }); + const vorbisUncompressed = notCompressed(vorbisBank); + gate('every sample Vorbis-encoded', vorbisUncompressed.length === 0, vorbisUncompressed.slice(0, 5).join(', ')); + await writeFile(path.join(outDir, VORBIS_OUT), new Uint8Array(vorbisBank.writeSF2())); + + const sf3 = load(await readFile(path.join(outDir, VORBIS_OUT))); + gate('SF3 presets, generators, modulators and loop points match source', compareStructure('SF3', source, sf3) === 0); + + await StbVorbis.ready; + const errors = []; + const exceptions = []; + let lengthBad = 0; + for (const [i, s] of sf3.samples.entries()) { + const decoded = StbVorbis.decode(s.getRawData(true)).channels[0] ?? new Float32Array(0); + const pcm = new Int16Array(sourcePcm[i].buffer, sourcePcm[i].byteOffset, sourcePcm[i].byteLength / 2); + const rmsAt = (offset) => { + let sum = 0; + let count = 0; + for (let j = Math.max(0, -offset); j < pcm.length && j + offset < decoded.length; j++) { + const e = decoded[j + offset] - pcm[j] / 32768; + sum += e * e; + count++; + } + return Math.sqrt(sum / Math.max(1, count)); + }; + if (decoded.length !== pcm.length) { + // stb-vorbis 0.0.6 sometimes skips the final-frame trim (wasm/stb_vorbis.c:3413-3443) + // and returns extra tail. That is harmless only if playback never reaches the tail. + const modes = loopOnlyModes(source, source.samples[i]); + let best = 0; + let bestRms = Infinity; + if (decoded.length > pcm.length) { + for (let offset = -SEAM; offset <= SEAM; offset++) { + const rms = rmsAt(offset); + if (offset !== 0 && rms < bestRms) [best, bestRms] = [offset, rms]; + } + } + const aligned = decoded.length > pcm.length && rmsAt(0) < bestRms; + const detail = + `sample ${i} ${s.name}: source ${pcm.length}, decoded ${decoded.length}, ` + + `rms at offset 0 ${dB(rmsAt(0))} dBFS, best other offset ${best} ${dB(bestRms)} dBFS, ` + + `zones (sampleModes, end/endloop offsets 1/12/3/50): ${modes.zones.join('; ') || 'none'}`; + if (aligned && modes.ok) exceptions.push(detail); + else { + lengthBad++; + console.log(` length mismatch: ${detail}`); + continue; + } + } + let sum = 0; + let peak = 0; + for (let j = 0; j < pcm.length; j++) { + const e = Math.abs(decoded[j] - pcm[j] / 32768); + sum += e * e; + if (e > peak) peak = e; + } + let seamSum = 0; + let seamCount = 0; + let seamPeak = 0; + const src = source.samples[i]; + if (src.loopEnd - src.loopStart > 1) { + for (const point of [src.loopStart, src.loopEnd]) { + for (let j = Math.max(0, point - SEAM); j < Math.min(pcm.length, point + SEAM); j++) { + const e = Math.abs(decoded[j] - pcm[j] / 32768); + seamSum += e * e; + seamCount++; + if (e > seamPeak) seamPeak = e; + } + } + } + errors.push({ + name: `${i} ${s.name}`, + rms: Math.sqrt(sum / Math.max(1, pcm.length)), + peak, + seamRms: seamCount ? Math.sqrt(seamSum / seamCount) : 0, + seamPeak, + }); + } + for (const line of exceptions) console.log(` allowed longer decode: ${line}`); + gate( + 'every SF3 sample decodes (stb-vorbis) to the source length, or longer only where it is aligned and never played past the loop end', + lengthBad === 0 && sf3.samples.length === source.samples.length, + `${sf3.samples.length - lengthBad}/${source.samples.length} samples, ${exceptions.length} allowed longer`, + ); + + const table = (title, key, peakKey) => { + console.log(`${title} (error in dBFS, rms / peak):`); + for (const e of [...errors].sort((x, y) => y[key] - x[key]).slice(0, TOP)) + console.log(` ${dB(e[key]).padStart(6)} / ${dB(e[peakKey]).padStart(6)} ${e.name}`); + }; + table(`top ${TOP} samples by error, whole sample`, 'rms', 'peak'); + table(`top ${TOP} samples by error, ${SEAM} samples either side of each loop point`, 'seamRms', 'seamPeak'); + + await writeFile(path.join(outDir, LICENSE), license); + for (const name of [FLAC_OUT, VORBIS_OUT, LICENSE]) { + const { size } = await stat(path.join(outDir, name)); + console.log(`${path.join(outDir, name)}: ${size} bytes (${(size / 1048576).toFixed(2)} MiB)`); + } + console.log(`Vorbis quality ${quality}`); + } finally { + await rm(scratch, { recursive: true, force: true }); + } + if (failures.length) { + console.log(`FAILED: ${failures.join('; ')}`); + process.exitCode = 1; + } else console.log('all gates passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/check-soundfonts.mjs b/scripts/check-soundfonts.mjs index 253c79dc..8efc6d8e 100644 --- a/scripts/check-soundfonts.mjs +++ b/scripts/check-soundfonts.mjs @@ -4,6 +4,7 @@ import { createServer } from 'node:http'; import { dirname, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; import { chromium, expect } from '@playwright/test'; +import { BasicSoundBank } from 'spessasynth_core'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const dist = resolve(root, 'dist'); @@ -25,25 +26,20 @@ const server = createServer(async (request, response) => { await new Promise((done) => server.listen(0, '127.0.0.1', done)); const origin = `http://127.0.0.1:${server.address().port}`; -// A real, decodable sustained note. CI needs no network or soundfont-host uptime. -const wav = Buffer.alloc(44 + 48000 * 2); -wav.write('RIFF'); wav.writeUInt32LE(wav.length - 8, 4); wav.write('WAVEfmt ', 8); -wav.writeUInt32LE(16, 16); wav.writeUInt16LE(1, 20); wav.writeUInt16LE(1, 22); -wav.writeUInt32LE(48000, 24); wav.writeUInt32LE(96000, 28); wav.writeUInt16LE(2, 32); wav.writeUInt16LE(16, 34); -wav.write('data', 36); wav.writeUInt32LE(wav.length - 44, 40); -for (let n = 0; n < 48000; n++) wav.writeInt16LE(Math.round(Math.sin(n * 2 * Math.PI * 261.63 / 48000) * 12000), 44 + n * 2); -const sample = `MIDI.Soundfont.acoustic_grand_piano = {"C4":"data:audio/mp3;base64,${wav.toString('base64')}",}`; +// A real SoundFont (spessasynth's one-preset saw-wave bank). CI needs no network or bank-host uptime. +const bank = Buffer.from(BasicSoundBank.getSampleSoundBankFile()); +const bankUrl = 'https://mongoose.world/sounds/soundfonts/MuseScore_General.sf3'; const sampleRequests = []; let failNext = true; const browser = await chromium.launch(); try { const context = await browser.newContext({ serviceWorkers: 'block' }); await context.route('**/*', (route) => route.request().url().startsWith(origin) ? route.continue() : route.abort()); - await context.route('https://gleitz.github.io/midi-js-soundfonts/**', async (route) => { + await context.route(bankUrl, async (route) => { sampleRequests.push(route.request().url()); if (failNext) { failNext = false; await route.fulfill({ status: 503, body: '' }); return; } - const body = process.env.LIVE_SOUNDFONTS === '1' ? await (await fetch(route.request().url())).text() : sample; - await route.fulfill({ status: 200, body, contentType: 'application/javascript', headers: { 'Access-Control-Allow-Origin': '*' } }); + const body = process.env.LIVE_SOUNDFONTS === '1' ? Buffer.from(await (await fetch(bankUrl)).arrayBuffer()) : bank; + await route.fulfill({ status: 200, body, contentType: 'application/octet-stream', headers: { 'Access-Control-Allow-Origin': '*' } }); }); await context.routeWebSocket('**/*', (socket) => socket.close()); const page = await context.newPage(); @@ -61,16 +57,16 @@ try { await panel.getByRole('combobox', { name: 'Output device', exact: true }).selectOption('soundfont-synth'); assert.equal(sampleRequests.length, 0, 'Opening MIDI and selecting an output must not fetch samples'); assert.ok(!requests.includes(`/${renderer.file}`), 'Opening MIDI must not load the sample renderer'); - const connect = panel.getByRole('button', { name: 'Connect output device', exact: true }); - await connect.click(); - await expect(panel.getByRole('status')).toContainText('Soundfont could not load', { timeout: 30000 }); - await expect(connect).toBeEnabled(); - await expect(panel.getByRole('combobox', { name: 'Output device', exact: true })).toHaveValue('soundfont-synth'); - await connect.click(); - await expect(panel).toContainText('Soundfont Synthesizer · Musyng Kite', { timeout: 30000 }).catch(async (error) => { + await panel.getByRole('button', { name: 'Connect output device', exact: true }).click(); + // A failed download leaves the built-in synth playing; Reconnect retries. + await expect(panel.getByRole('status')).toContainText('MuseScore General could not load', { timeout: 30000 }); + await expect(panel).toContainText('Soundfont Synthesizer · MuseScore General'); + await panel.getByRole('button', { name: 'Reconnect soundfont', exact: true }).click(); + await expect(panel.getByRole('status')).toContainText('MuseScore General ready.', { timeout: 30000 }).catch(async (error) => { console.error(await panel.innerText()); throw error; }); + assert.equal(sampleRequests.length, 2); assert.ok(requests.includes(`/${renderer.file}`)); // Observe real PCM after the master bus, not a mocked send() call. @@ -112,14 +108,8 @@ try { assert.equal(await page.evaluate(() => window.mudClient.media.cacophony.context.state), 'suspended', 'Incoming MIDI cannot undo Escape'); await page.getByRole('button', { name: 'Resume sounds', exact: true }).click(); await expect.poll(() => page.evaluate(() => window.mudClient.media.cacophony.context.state)).toBe('running'); - - for (const [id, name] of [['FluidR3', 'FluidR3'], ['FatBoy', 'FatBoy']]) { - await panel.getByRole('combobox', { name: 'Soundfont collection', exact: true }).selectOption(id); - await panel.getByRole('button', { name: 'Reconnect soundfont', exact: true }).click(); - await expect(panel).toContainText(`Soundfont Synthesizer · ${name}`, { timeout: 30000 }); - await note(); - await expect.poll(energy).toBeGreaterThan(0.001); - } + await note(); + await expect.poll(energy).toBeGreaterThan(0.001); await mkdir(resolve(root, 'screenshots'), { recursive: true }); await panel.evaluate((node) => { for (let parent = node.parentElement; parent; parent = parent.parentElement) parent.scrollTop = 0; }); await page.screenshot({ path: resolve(root, 'screenshots/pr24-midi-desktop.png'), fullPage: true }); @@ -133,5 +123,5 @@ try { assert.deepEqual(errors, []); await panel.getByRole('button', { name: 'Disconnect output device', exact: true }).click(); await expect(panel.getByRole('button', { name: 'Connect output device', exact: true })).toBeVisible(); - console.log(`PASS: soundfont lazy loading, failure/retry, all three collections, real PCM through named effects, chain removal/recreation, Escape/Resume, axe and mobile reflow${process.env.LIVE_SOUNDFONTS === '1' ? ' (live MP3 samples)' : ''}.`); + console.log(`PASS: SoundFont lazy loading, failure/retry, real PCM through named effects, chain removal/recreation, Escape/Resume, axe and mobile reflow${process.env.LIVE_SOUNDFONTS === '1' ? ' (live MuseScore_General.sf3)' : ''}.`); } finally { await browser.close(); await new Promise((done) => server.close(done)); } diff --git a/src/MidiService.ts b/src/MidiService.ts index 354aa2a7..34cc8e48 100644 --- a/src/MidiService.ts +++ b/src/MidiService.ts @@ -1,7 +1,7 @@ import { virtualMidiService, type MidiOutputSink } from './VirtualMidiService'; import JZZ from 'jzz'; -import { usePreferences } from './stores/preferencesStore'; -import { getSoundfont, SOUNDFONT_OUTPUT } from './midi/soundfonts'; +import { usePreferences } from './stores/preferencesStore'; +import { SOUNDFONT_NAME, SOUNDFONT_OUTPUT } from './midi/soundfonts'; export interface MidiDevice { id: string; @@ -85,22 +85,22 @@ class MidiService { private outputDevice: MidiOutputSink | null = null; private inputCallback: MidiInputCallback | null = null; private deviceChangeCallbacks: Set = new Set(); - private deviceWatcher: JzzWatcher | null = null; - private outputGeneration = 0; - private pendingOutput: MidiOutputSink | null = null; - private soundfontMessage = ''; - private readonly soundfontListeners = new Set<() => void>(); - - getSoundfontMessage = (): string => this.soundfontMessage; - subscribeSoundfont = (listener: () => void): (() => void) => { - this.soundfontListeners.add(listener); - return () => { this.soundfontListeners.delete(listener); }; - }; - - private reportSoundfont(message: string): void { - this.soundfontMessage = message; - for (const listener of this.soundfontListeners) listener(); - } + private deviceWatcher: JzzWatcher | null = null; + private outputGeneration = 0; + private pendingOutput: MidiOutputSink | null = null; + private soundfontMessage = ''; + private readonly soundfontListeners = new Set<() => void>(); + + getSoundfontMessage = (): string => this.soundfontMessage; + subscribeSoundfont = (listener: () => void): (() => void) => { + this.soundfontListeners.add(listener); + return () => { this.soundfontListeners.delete(listener); }; + }; + + private reportSoundfont(message: string): void { + this.soundfontMessage = message; + for (const listener of this.soundfontListeners) listener(); + } private connectionState: { inputConnected: boolean; outputConnected: boolean; @@ -165,8 +165,8 @@ class MidiService { return devices; } - getOutputDevices(): MidiDevice[] { - const devices: MidiDevice[] = [{ id: SOUNDFONT_OUTPUT, name: 'Soundfont Synthesizer' }]; + getOutputDevices(): MidiDevice[] { + const devices: MidiDevice[] = [{ id: SOUNDFONT_OUTPUT, name: 'Soundfont Synthesizer' }]; // Add virtual synthesizer if initialized if (virtualMidiService.initialized) { @@ -285,48 +285,47 @@ class MidiService { } } - async connectOutputDevice(deviceId: string): Promise { - const generation = ++this.outputGeneration; - this.pendingOutput?.close(); - this.pendingOutput = null; - try { - if (deviceId === SOUNDFONT_OUTPUT) { - const font = getSoundfont(usePreferences.getState().midi.midiJsSoundfont); - this.reportSoundfont(`Loading ${font.name}…`); - const { SoundfontOutput } = await import('./midi/SoundfontOutput'); - if (generation !== this.outputGeneration) return false; - const fallback = await virtualMidiService.getVirtualPort(); - if (!fallback) throw new Error('Built-in synth is unavailable.'); - if (generation !== this.outputGeneration) { fallback.close(); return false; } - const output = new SoundfontOutput(font.id, () => virtualMidiService.getAudioOutput(), fallback, - (message) => { if (generation === this.outputGeneration) this.reportSoundfont(message); }, - (listener) => virtualMidiService.subscribeAudioOutput(listener), - () => { - if (generation === this.outputGeneration) this.disconnectDevice('output'); - }); - this.pendingOutput = output; - await output.ready(); - if (generation !== this.outputGeneration) { output.close(); return false; } - this.pendingOutput = null; - this.outputDevice?.close(); - this.outputDevice = output; - this.connectionState.outputConnected = true; - this.connectionState.outputDeviceId = deviceId; - this.connectionState.outputDeviceName = `Soundfont Synthesizer · ${font.name}`; - usePreferences.getState().setMidi({ ...usePreferences.getState().midi, lastOutputDeviceId: deviceId }); - this.intentionalDisconnectFlags.output = false; - return true; - } - - this.reportSoundfont(''); - // Commit a replacement only after it opens successfully. + async connectOutputDevice(deviceId: string): Promise { + const generation = ++this.outputGeneration; + this.pendingOutput?.close(); + this.pendingOutput = null; + try { + if (deviceId === SOUNDFONT_OUTPUT) { + this.reportSoundfont(`Loading ${SOUNDFONT_NAME}…`); + const { SoundfontOutput } = await import('./midi/SoundfontOutput'); + if (generation !== this.outputGeneration) return false; + const fallback = await virtualMidiService.getVirtualPort(); + if (!fallback) throw new Error('Built-in synth is unavailable.'); + if (generation !== this.outputGeneration) { fallback.close(); return false; } + const output = new SoundfontOutput(() => virtualMidiService.getAudioOutput(), fallback, + (message) => { if (generation === this.outputGeneration) this.reportSoundfont(message); }, + (listener) => virtualMidiService.subscribeAudioOutput(listener), + () => { + if (generation === this.outputGeneration) this.disconnectDevice('output'); + }); + this.pendingOutput = output; + await output.ready(); + if (generation !== this.outputGeneration) { output.close(); return false; } + this.pendingOutput = null; + this.outputDevice?.close(); + this.outputDevice = output; + this.connectionState.outputConnected = true; + this.connectionState.outputDeviceId = deviceId; + this.connectionState.outputDeviceName = `Soundfont Synthesizer · ${SOUNDFONT_NAME}`; + usePreferences.getState().setMidi({ ...usePreferences.getState().midi, lastOutputDeviceId: deviceId }); + this.intentionalDisconnectFlags.output = false; + return true; + } + + this.reportSoundfont(''); + // Commit a replacement only after it opens successfully. // Handle virtual synthesizer connection if (deviceId === 'virtual-synth') { - const virtualPort = await virtualMidiService.getVirtualPort(); - if (virtualPort) { - if (generation !== this.outputGeneration) { virtualPort.close(); return false; } - this.outputDevice?.close(); + const virtualPort = await virtualMidiService.getVirtualPort(); + if (virtualPort) { + if (generation !== this.outputGeneration) { virtualPort.close(); return false; } + this.outputDevice?.close(); this.outputDevice = virtualPort; this.connectionState.outputConnected = true; this.connectionState.outputDeviceId = deviceId; @@ -350,10 +349,10 @@ class MidiService { // Handle hardware MIDI device connection using JZZ if (!this.jzz) return false; - const output = await this.jzz.openMidiOut(deviceId); - if (generation !== this.outputGeneration) { output.close(); return false; } - this.outputDevice?.close(); - this.outputDevice = output; + const output = await this.jzz.openMidiOut(deviceId); + if (generation !== this.outputGeneration) { output.close(); return false; } + this.outputDevice?.close(); + this.outputDevice = output; // Update connection state this.connectionState.outputConnected = true; @@ -375,12 +374,12 @@ class MidiService { console.log(`Connected to MIDI output: ${this.connectionState.outputDeviceName}`); return true; - } catch (error) { - if (generation === this.outputGeneration) { - this.pendingOutput?.close(); - this.pendingOutput = null; - if (deviceId === SOUNDFONT_OUTPUT) this.reportSoundfont('Soundfont could not load. Check your connection and try Connect again, or choose Virtual Synthesizer.'); - } + } catch (error) { + if (generation === this.outputGeneration) { + this.pendingOutput?.close(); + this.pendingOutput = null; + if (deviceId === SOUNDFONT_OUTPUT) this.reportSoundfont('Soundfont could not load. Check your connection and try Connect again, or choose Virtual Synthesizer.'); + } console.error("Failed to connect to MIDI output:", error); return false; } @@ -438,18 +437,18 @@ class MidiService { this.connectionState.inputDeviceId = undefined; this.connectionState.inputDeviceName = undefined; console.log("Disconnected input device"); - } else if (deviceType === 'output') { - this.outputGeneration++; - this.pendingOutput?.close(); - this.pendingOutput = null; + } else if (deviceType === 'output') { + this.outputGeneration++; + this.pendingOutput?.close(); + this.pendingOutput = null; if (this.outputDevice) { this.outputDevice.close?.(); this.outputDevice = null; } this.connectionState.outputConnected = false; this.connectionState.outputDeviceId = undefined; - this.connectionState.outputDeviceName = undefined; - this.reportSoundfont(''); + this.connectionState.outputDeviceName = undefined; + this.reportSoundfont(''); console.log("Disconnected output device"); } } @@ -468,10 +467,10 @@ class MidiService { } } - disconnect(): void { - this.outputGeneration++; - this.pendingOutput?.close(); - this.pendingOutput = null; + disconnect(): void { + this.outputGeneration++; + this.pendingOutput?.close(); + this.pendingOutput = null; if (this.inputDevice) { this.inputDevice.close?.(); this.inputDevice = null; @@ -488,8 +487,8 @@ class MidiService { this.connectionState.inputDeviceId = undefined; this.connectionState.outputDeviceId = undefined; this.connectionState.inputDeviceName = undefined; - this.connectionState.outputDeviceName = undefined; - this.reportSoundfont(''); + this.connectionState.outputDeviceName = undefined; + this.reportSoundfont(''); } get isSupported(): boolean { diff --git a/src/components/MidiStatus.css b/src/components/MidiStatus.css index 49d4efa1..03e56165 100644 --- a/src/components/MidiStatus.css +++ b/src/components/MidiStatus.css @@ -7,6 +7,8 @@ .midi-device-card > div:first-child { font-weight: 600; } .midi-device-card select { width: 100%; min-width: 0; margin: 0 0 var(--space-2) !important; } .midi-soundfont-card h4 { margin: 0 0 var(--space-3); color: var(--color-primary); font-size: 1rem; } +.midi-soundfont-card p { margin: 0 0 var(--space-2); color: var(--color-text-secondary); font-size: var(--font-size-sm); line-height: 1.6; } +.midi-soundfont-card a { color: var(--color-primary); font-size: var(--font-size-sm); overflow-wrap: anywhere; } .midi-outcome { color: var(--color-text-secondary); line-height: 1.6; font-size: var(--font-size-sm); } .midi-outcome:empty { display: none; } .midi-status button, .midi-status select { min-height: 44px; max-width: 100%; white-space: normal; } @@ -18,7 +20,7 @@ .midi-status .midi-device-card > button.midi-preview { background: var(--color-bg-surface); color: var(--color-primary); } .midi-status button:disabled { opacity: .55; cursor: default; } .midi-status input[type="range"] { accent-color: var(--color-primary); } -.midi-status button:focus-visible, .midi-status select:focus-visible, .soundfont-picker select:focus-visible, .soundfont-picker a:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 3px; } +.midi-status button:focus-visible, .midi-status select:focus-visible, .midi-soundfont-card a:focus-visible { outline: 2px solid var(--color-focus-ring); outline-offset: 3px; } .midi-status details { border-top: 1px solid var(--color-separator); padding: var(--space-3) 0; } .midi-status summary { cursor: pointer; line-height: 1.6; } .midi-status details input { max-width: 100%; box-sizing: border-box; } diff --git a/src/components/MidiStatus.test.tsx b/src/components/MidiStatus.test.tsx index 50e84032..18b3c61e 100644 --- a/src/components/MidiStatus.test.tsx +++ b/src/components/MidiStatus.test.tsx @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type MudClient from '../client'; import MidiStatus from './MidiStatus'; import { midiService } from '../MidiService'; +import { usePreferences } from '../stores/preferencesStore'; const testState = vi.hoisted(() => ({ connectionStatus: { @@ -125,6 +126,32 @@ describe('MidiStatus lifecycle refreshes', () => { expect(document.activeElement).toBe(screen.getByRole('button', { name: 'Connect input device' })); }); + it('connects an output once per Connect, though the connect saves it as the last output', async () => { + vi.mocked(midiService.getOutputDevices).mockReturnValue([{ id: 'out', name: 'Synth', type: 'output' } as never]); + vi.mocked(midiService.canReconnectToDevice).mockReturnValue(true); + const preferences = usePreferences.getState() as { midi: { lastOutputDeviceId?: string } }; + const connectOutputDevice = vi.fn(async () => { + testState.connectionStatus.outputConnected = true; + testState.connectionStatus.outputDeviceId = 'out'; + preferences.midi.lastOutputDeviceId = 'out'; + return true; + }); + try { + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + fireEvent.change(screen.getByRole('combobox', { name: 'Output device' }), { target: { value: 'out' } }); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Connect output device' })); + await vi.advanceTimersByTimeAsync(0); + }); + expect(connectOutputDevice).toHaveBeenCalledTimes(1); + } finally { + preferences.midi.lastOutputDeviceId = undefined; + vi.mocked(midiService.canReconnectToDevice).mockReturnValue(false); + } + }); + it('keeps the selected device and action available after a failed connection', async () => { vi.mocked(midiService.getInputDevices).mockReturnValue([{ id: 'in', name: 'Keyboard', type: 'input' } as never]); const connectInputDevice = vi.fn(async () => { throw new Error('Permission denied'); }); diff --git a/src/components/MidiStatus.tsx b/src/components/MidiStatus.tsx index 28ed395f..ef6e5a58 100644 --- a/src/components/MidiStatus.tsx +++ b/src/components/MidiStatus.tsx @@ -6,8 +6,7 @@ import { midiService, type MidiDevice } from "../MidiService"; import { usePreferences } from "../stores/preferencesStore"; import type { GMCPClientMidi } from "../gmcp/Client/Midi"; import { virtualMidiService } from "../VirtualMidiService"; -import { SOUNDFONT_OUTPUT } from '../midi/soundfonts'; -import SoundfontPicker from './SoundfontPicker'; +import { SOUNDFONT_NAME, SOUNDFONT_OUTPUT } from '../midi/soundfonts'; import './MidiStatus.css'; interface MidiStatusProps { @@ -150,11 +149,14 @@ const MidiStatus: React.FC = ({ client }) => { const updateReconnectableDevices = async () => { const prefs = usePreferences.getState().midi; const intentionalFlags = midiService.intentionalDisconnectStatus; + // The live status, not this render's `connectionState`: after a Connect, + // the handler's closure still says disconnected while the service is connected. + const connected = midiService.connectionStatus; const newReconnectables: { input?: { id: string, name: string }; output?: { id: string, name: string } } = {}; // Check if last input device is available but not connected - if (prefs.lastInputDeviceId && !connectionState.inputConnected) { + if (prefs.lastInputDeviceId && !connected.inputConnected) { const device = inputDevices.find(d => d.id === prefs.lastInputDeviceId); if (device && midiService.canReconnectToDevice(prefs.lastInputDeviceId, 'input')) { @@ -183,7 +185,7 @@ const MidiStatus: React.FC = ({ client }) => { } // Check if last output device is available but not connected - if (prefs.lastOutputDeviceId && !connectionState.outputConnected) { + if (prefs.lastOutputDeviceId && !connected.outputConnected) { const device = outputDevices.find(d => d.id === prefs.lastOutputDeviceId); if (device && midiService.canReconnectToDevice(prefs.lastOutputDeviceId, 'output')) { @@ -598,7 +600,17 @@ const MidiStatus: React.FC = ({ client }) => {

Sampled instruments

- +

+ Choose Soundfont Synthesizer as your output to play the {SOUNDFONT_NAME} SoundFont. The + built-in synth plays while it downloads. +

+ + SoundFont credits & license (opens a new tab) + {connectionState.outputDeviceId === SOUNDFONT_OUTPUT &&