@waveform-playlist/midi 10.3.0 → 10.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.d.mts
CHANGED
|
@@ -72,8 +72,8 @@ interface MidiTrackConfig {
|
|
|
72
72
|
startTime?: number;
|
|
73
73
|
/** Override clip duration in seconds (default: derived from last note) */
|
|
74
74
|
duration?: number;
|
|
75
|
-
/** Sample rate for sample-based positioning
|
|
76
|
-
sampleRate
|
|
75
|
+
/** Sample rate for sample-based positioning — pass AudioContext.sampleRate */
|
|
76
|
+
sampleRate: number;
|
|
77
77
|
/** Merge all MIDI tracks from the file into one ClipTrack (default false) */
|
|
78
78
|
flatten?: boolean;
|
|
79
79
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -72,8 +72,8 @@ interface MidiTrackConfig {
|
|
|
72
72
|
startTime?: number;
|
|
73
73
|
/** Override clip duration in seconds (default: derived from last note) */
|
|
74
74
|
duration?: number;
|
|
75
|
-
/** Sample rate for sample-based positioning
|
|
76
|
-
sampleRate
|
|
75
|
+
/** Sample rate for sample-based positioning — pass AudioContext.sampleRate */
|
|
76
|
+
sampleRate: number;
|
|
77
77
|
/** Merge all MIDI tracks from the file into one ClipTrack (default false) */
|
|
78
78
|
flatten?: boolean;
|
|
79
79
|
}
|
package/dist/index.js
CHANGED
|
@@ -130,7 +130,7 @@ function useMidiTracks(configs) {
|
|
|
130
130
|
let cancelled = false;
|
|
131
131
|
const abortController = new AbortController();
|
|
132
132
|
const createTrackFromNotes = (config, notes, trackName, noteDuration, midiChannel, midiProgram) => {
|
|
133
|
-
const sampleRate = config.sampleRate
|
|
133
|
+
const sampleRate = config.sampleRate;
|
|
134
134
|
const clipDuration = config.duration ?? noteDuration;
|
|
135
135
|
const clip = (0, import_core.createClipFromSeconds)({
|
|
136
136
|
startTime: config.startTime ?? 0,
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts","../src/parseMidiFile.ts","../src/useMidiTracks.ts"],"sourcesContent":["export { parseMidiFile, parseMidiUrl } from './parseMidiFile';\nexport type { ParsedMidi, ParsedMidiTrack, ParseMidiOptions } from './parseMidiFile';\nexport { useMidiTracks } from './useMidiTracks';\nexport type { MidiTrackConfig, UseMidiTracksReturn } from './useMidiTracks';\n","import { Midi } from '@tonejs/midi';\nimport type { MidiNoteData } from '@waveform-playlist/core';\n\nexport interface ParsedMidiTrack {\n /** Track name from the MIDI file */\n name: string;\n /** Parsed notes in MidiNoteData format */\n notes: MidiNoteData[];\n /** Duration in seconds (end of last note) */\n duration: number;\n /** MIDI channel (9/10 = percussion) */\n channel: number;\n /** Instrument name from MIDI program change */\n instrument: string;\n /** GM program number (0-127) from MIDI program change event */\n programNumber: number;\n}\n\nexport interface ParsedMidi {\n /** Individual MIDI tracks with their notes */\n tracks: ParsedMidiTrack[];\n /** Total duration in seconds (max of all track durations) */\n duration: number;\n /** Song name from MIDI header */\n name: string;\n /** First tempo in BPM (default 120 if none specified) */\n bpm: number;\n /** Time signature as [numerator, denominator] (default [4, 4]) */\n timeSignature: [number, number];\n}\n\nexport interface ParseMidiOptions {\n /** When true, merges all MIDI tracks into a single ParsedMidiTrack */\n flatten?: boolean;\n}\n\n/**\n * Title-case an instrument name from @tonejs/midi.\n * \"acoustic grand piano\" → \"Acoustic Grand Piano\"\n * \"electric bass (finger)\" → \"Electric Bass (Finger)\"\n */\nfunction titleCase(str: string): string {\n return str.replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\nfunction mapNotes(track: Midi['tracks'][number]): MidiNoteData[] {\n return track.notes.map((note) => ({\n midi: note.midi,\n name: note.name,\n time: note.time,\n duration: note.duration,\n velocity: note.velocity,\n channel: track.channel,\n }));\n}\n\nfunction getTrackDuration(notes: MidiNoteData[]): number {\n if (notes.length === 0) return 0;\n return Math.max(...notes.map((n) => n.time + n.duration));\n}\n\n/**\n * Parse a MIDI file from an ArrayBuffer.\n *\n * Returns structured data with tracks, notes, tempo, and time signature.\n * Notes are already in seconds (tempo-adjusted by @tonejs/midi).\n */\nexport function parseMidiFile(data: ArrayBuffer, options: ParseMidiOptions = {}): ParsedMidi {\n const midi = new Midi(data);\n\n const bpm = midi.header.tempos.length > 0 ? midi.header.tempos[0].bpm : 120;\n\n const timeSig =\n midi.header.timeSignatures.length > 0 ? midi.header.timeSignatures[0].timeSignature : [4, 4];\n\n // Parse all tracks that have notes\n const parsedTracks: ParsedMidiTrack[] = midi.tracks\n .filter((track) => track.notes.length > 0)\n .map((track) => {\n const notes = mapNotes(track);\n const instrument = track.instrument.name;\n const programNumber = track.instrument.number;\n // Channel 9 = GM percussion. Use \"Drums\" for clarity.\n // For melodic tracks: prefer GM instrument name when program is non-default (> 0),\n // since @tonejs/midi defaults to \"acoustic grand piano\" even with no program change.\n // Fall back to track name, then channel number.\n let name: string;\n if (track.channel === 9) {\n name = 'Drums';\n } else if (programNumber > 0) {\n name = titleCase(instrument);\n } else {\n name = track.name.trim() || titleCase(instrument) || `Channel ${track.channel + 1}`;\n }\n return {\n name,\n notes,\n duration: getTrackDuration(notes),\n channel: track.channel,\n instrument,\n programNumber: track.instrument.number,\n };\n });\n\n if (options.flatten && parsedTracks.length > 0) {\n const allNotes = parsedTracks.flatMap((t) => t.notes);\n allNotes.sort((a, b) => a.time - b.time);\n const duration = getTrackDuration(allNotes);\n\n return {\n tracks: [\n {\n name: midi.name || 'MIDI',\n notes: allNotes,\n duration,\n channel: parsedTracks[0].channel,\n instrument: parsedTracks[0].instrument,\n programNumber: parsedTracks[0].programNumber,\n },\n ],\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n }\n\n const duration = parsedTracks.length > 0 ? Math.max(...parsedTracks.map((t) => t.duration)) : 0;\n\n return {\n tracks: parsedTracks,\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n}\n\n/**\n * Fetch and parse a MIDI file from a URL.\n *\n * Supports AbortSignal for cancellation (e.g., component unmount cleanup).\n */\nexport async function parseMidiUrl(\n url: string,\n options: ParseMidiOptions = {},\n signal?: AbortSignal\n): Promise<ParsedMidi> {\n const response = await fetch(url, { signal });\n if (!response.ok) {\n throw new Error(`Failed to fetch MIDI file ${url}: ${response.statusText}`);\n }\n const buffer = await response.arrayBuffer();\n return parseMidiFile(buffer, options);\n}\n","import { useState, useEffect, useRef } from 'react';\nimport {\n type ClipTrack,\n type MidiNoteData,\n createClipFromSeconds,\n createTrack,\n} from '@waveform-playlist/core';\nimport { parseMidiFile } from './parseMidiFile';\n\n/**\n * Configuration for a single MIDI track to load.\n *\n * MIDI data can be provided in two ways:\n * 1. `src` — URL to a .mid file (fetched, parsed with @tonejs/midi)\n * 2. `midiNotes` — Pre-parsed notes (skip fetch+parse)\n */\nexport interface MidiTrackConfig {\n /** URL to .mid file */\n src?: string;\n /** Pre-parsed MIDI notes (skip fetch+parse) */\n midiNotes?: MidiNoteData[];\n /** Track display name */\n name?: string;\n /** Whether this track is muted */\n muted?: boolean;\n /** Whether this track is soloed */\n soloed?: boolean;\n /** Track volume (default 1.0) */\n volume?: number;\n /** Stereo pan (default 0) */\n pan?: number;\n /** Track color */\n color?: string;\n /** Clip position on timeline in seconds (default 0) */\n startTime?: number;\n /** Override clip duration in seconds (default: derived from last note) */\n duration?: number;\n /** Sample rate for sample-based positioning (default 44100) */\n sampleRate?: number;\n /** Merge all MIDI tracks from the file into one ClipTrack (default false) */\n flatten?: boolean;\n}\n\nexport interface UseMidiTracksReturn {\n /** Loaded ClipTrack array with midiNotes on clips */\n tracks: ClipTrack[];\n /** Whether any tracks are still loading */\n loading: boolean;\n /** Error message if loading failed, null otherwise */\n error: string | null;\n /** Number of tracks that have finished loading */\n loadedCount: number;\n /** Total number of tracks (known after parsing) */\n totalCount: number;\n}\n\n/**\n * Hook to load MIDI files and convert to ClipTrack format with midiNotes.\n *\n * All tracks are returned at once after loading completes. This ensures\n * all track controls and layout containers appear simultaneously in React,\n * while canvas rendering is deferred to the UI layer for progressive drawing.\n *\n * @example\n * ```typescript\n * const { tracks, loading, error } = useMidiTracks([\n * { src: '/music/song.mid', name: 'Piano' },\n * ]);\n *\n * // Pre-parsed notes (no fetch)\n * const { tracks } = useMidiTracks([\n * { midiNotes: myNotes, name: 'Synth Lead', duration: 30 },\n * ]);\n * ```\n */\nexport function useMidiTracks(configs: MidiTrackConfig[]): UseMidiTracksReturn {\n const [tracks, setTracks] = useState<ClipTrack[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [loadedCount, setLoadedCount] = useState(0);\n const [totalCount, setTotalCount] = useState(configs.length);\n\n // Cache fetched ArrayBuffers by URL so re-parses skip the network.\n const bufferCacheRef = useRef(new Map<string, ArrayBuffer>());\n\n useEffect(() => {\n if (configs.length === 0) {\n setTracks([]);\n setLoading(false);\n setLoadedCount(0);\n setTotalCount(0);\n return;\n }\n\n let cancelled = false;\n const abortController = new AbortController();\n\n const createTrackFromNotes = (\n config: MidiTrackConfig,\n notes: MidiNoteData[],\n trackName: string,\n noteDuration: number,\n midiChannel?: number,\n midiProgram?: number\n ): ClipTrack => {\n const sampleRate = config.sampleRate ?? 44100;\n const clipDuration = config.duration ?? noteDuration;\n\n const clip = createClipFromSeconds({\n startTime: config.startTime ?? 0,\n duration: clipDuration,\n sampleRate,\n sourceDuration: clipDuration,\n midiNotes: notes,\n midiChannel,\n midiProgram,\n name: trackName,\n });\n\n return createTrack({\n name: trackName,\n clips: [clip],\n muted: config.muted ?? false,\n soloed: config.soloed ?? false,\n volume: config.volume ?? 1.0,\n pan: config.pan ?? 0,\n color: config.color,\n });\n };\n\n // Check if all src URLs are already cached — if so, skip loading state\n // to avoid flashing the loading overlay on re-parse-only changes.\n const allCached = configs.every((c) => !c.src || bufferCacheRef.current.has(c.src));\n\n const loadTracks = async () => {\n try {\n if (!allCached) {\n setLoading(true);\n setLoadedCount(0);\n }\n setError(null);\n\n const allTracks: ClipTrack[] = [];\n\n for (const config of configs) {\n if (cancelled) break;\n\n if (config.midiNotes) {\n // Pre-parsed notes — no fetch needed\n const notes = config.midiNotes;\n const duration =\n notes.length > 0 ? Math.max(...notes.map((n) => n.time + n.duration)) : 0;\n const trackName = config.name || 'MIDI Track';\n allTracks.push(createTrackFromNotes(config, notes, trackName, duration));\n } else if (config.src) {\n // Use cached buffer if available, otherwise fetch\n let buffer = bufferCacheRef.current.get(config.src);\n if (!buffer) {\n const response = await fetch(config.src, {\n signal: abortController.signal,\n });\n if (!response.ok) {\n throw new Error(`Failed to fetch ${config.src}: ${response.statusText}`);\n }\n buffer = await response.arrayBuffer();\n bufferCacheRef.current.set(config.src, buffer);\n }\n\n const parsed = parseMidiFile(buffer, {\n flatten: config.flatten,\n });\n for (const parsedTrack of parsed.tracks) {\n if (cancelled) break;\n const trackName = parsedTrack.name;\n allTracks.push(\n createTrackFromNotes(\n config,\n parsedTrack.notes,\n trackName,\n parsedTrack.duration,\n parsedTrack.channel,\n parsedTrack.programNumber\n )\n );\n }\n } else {\n throw new Error('MIDI track config must provide src or midiNotes');\n }\n }\n\n if (!cancelled) {\n setTracks(allTracks);\n setLoadedCount(allTracks.length);\n setTotalCount(allTracks.length);\n setLoading(false);\n }\n } catch (err) {\n if (!cancelled) {\n const errorMessage = err instanceof Error ? err.message : 'Unknown error loading MIDI';\n setError(errorMessage);\n setLoading(false);\n console.error('Error loading MIDI tracks:', err);\n }\n }\n };\n\n loadTracks();\n\n return () => {\n cancelled = true;\n abortController.abort();\n };\n }, [configs]);\n\n return { tracks, loading, error, loadedCount, totalCount };\n}\n"],"mappings":"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","names":["duration"]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/parseMidiFile.ts","../src/useMidiTracks.ts"],"sourcesContent":["export { parseMidiFile, parseMidiUrl } from './parseMidiFile';\nexport type { ParsedMidi, ParsedMidiTrack, ParseMidiOptions } from './parseMidiFile';\nexport { useMidiTracks } from './useMidiTracks';\nexport type { MidiTrackConfig, UseMidiTracksReturn } from './useMidiTracks';\n","import { Midi } from '@tonejs/midi';\nimport type { MidiNoteData } from '@waveform-playlist/core';\n\nexport interface ParsedMidiTrack {\n /** Track name from the MIDI file */\n name: string;\n /** Parsed notes in MidiNoteData format */\n notes: MidiNoteData[];\n /** Duration in seconds (end of last note) */\n duration: number;\n /** MIDI channel (9/10 = percussion) */\n channel: number;\n /** Instrument name from MIDI program change */\n instrument: string;\n /** GM program number (0-127) from MIDI program change event */\n programNumber: number;\n}\n\nexport interface ParsedMidi {\n /** Individual MIDI tracks with their notes */\n tracks: ParsedMidiTrack[];\n /** Total duration in seconds (max of all track durations) */\n duration: number;\n /** Song name from MIDI header */\n name: string;\n /** First tempo in BPM (default 120 if none specified) */\n bpm: number;\n /** Time signature as [numerator, denominator] (default [4, 4]) */\n timeSignature: [number, number];\n}\n\nexport interface ParseMidiOptions {\n /** When true, merges all MIDI tracks into a single ParsedMidiTrack */\n flatten?: boolean;\n}\n\n/**\n * Title-case an instrument name from @tonejs/midi.\n * \"acoustic grand piano\" → \"Acoustic Grand Piano\"\n * \"electric bass (finger)\" → \"Electric Bass (Finger)\"\n */\nfunction titleCase(str: string): string {\n return str.replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\nfunction mapNotes(track: Midi['tracks'][number]): MidiNoteData[] {\n return track.notes.map((note) => ({\n midi: note.midi,\n name: note.name,\n time: note.time,\n duration: note.duration,\n velocity: note.velocity,\n channel: track.channel,\n }));\n}\n\nfunction getTrackDuration(notes: MidiNoteData[]): number {\n if (notes.length === 0) return 0;\n return Math.max(...notes.map((n) => n.time + n.duration));\n}\n\n/**\n * Parse a MIDI file from an ArrayBuffer.\n *\n * Returns structured data with tracks, notes, tempo, and time signature.\n * Notes are already in seconds (tempo-adjusted by @tonejs/midi).\n */\nexport function parseMidiFile(data: ArrayBuffer, options: ParseMidiOptions = {}): ParsedMidi {\n const midi = new Midi(data);\n\n const bpm = midi.header.tempos.length > 0 ? midi.header.tempos[0].bpm : 120;\n\n const timeSig =\n midi.header.timeSignatures.length > 0 ? midi.header.timeSignatures[0].timeSignature : [4, 4];\n\n // Parse all tracks that have notes\n const parsedTracks: ParsedMidiTrack[] = midi.tracks\n .filter((track) => track.notes.length > 0)\n .map((track) => {\n const notes = mapNotes(track);\n const instrument = track.instrument.name;\n const programNumber = track.instrument.number;\n // Channel 9 = GM percussion. Use \"Drums\" for clarity.\n // For melodic tracks: prefer GM instrument name when program is non-default (> 0),\n // since @tonejs/midi defaults to \"acoustic grand piano\" even with no program change.\n // Fall back to track name, then channel number.\n let name: string;\n if (track.channel === 9) {\n name = 'Drums';\n } else if (programNumber > 0) {\n name = titleCase(instrument);\n } else {\n name = track.name.trim() || titleCase(instrument) || `Channel ${track.channel + 1}`;\n }\n return {\n name,\n notes,\n duration: getTrackDuration(notes),\n channel: track.channel,\n instrument,\n programNumber: track.instrument.number,\n };\n });\n\n if (options.flatten && parsedTracks.length > 0) {\n const allNotes = parsedTracks.flatMap((t) => t.notes);\n allNotes.sort((a, b) => a.time - b.time);\n const duration = getTrackDuration(allNotes);\n\n return {\n tracks: [\n {\n name: midi.name || 'MIDI',\n notes: allNotes,\n duration,\n channel: parsedTracks[0].channel,\n instrument: parsedTracks[0].instrument,\n programNumber: parsedTracks[0].programNumber,\n },\n ],\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n }\n\n const duration = parsedTracks.length > 0 ? Math.max(...parsedTracks.map((t) => t.duration)) : 0;\n\n return {\n tracks: parsedTracks,\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n}\n\n/**\n * Fetch and parse a MIDI file from a URL.\n *\n * Supports AbortSignal for cancellation (e.g., component unmount cleanup).\n */\nexport async function parseMidiUrl(\n url: string,\n options: ParseMidiOptions = {},\n signal?: AbortSignal\n): Promise<ParsedMidi> {\n const response = await fetch(url, { signal });\n if (!response.ok) {\n throw new Error(`Failed to fetch MIDI file ${url}: ${response.statusText}`);\n }\n const buffer = await response.arrayBuffer();\n return parseMidiFile(buffer, options);\n}\n","import { useState, useEffect, useRef } from 'react';\nimport {\n type ClipTrack,\n type MidiNoteData,\n createClipFromSeconds,\n createTrack,\n} from '@waveform-playlist/core';\nimport { parseMidiFile } from './parseMidiFile';\n\n/**\n * Configuration for a single MIDI track to load.\n *\n * MIDI data can be provided in two ways:\n * 1. `src` — URL to a .mid file (fetched, parsed with @tonejs/midi)\n * 2. `midiNotes` — Pre-parsed notes (skip fetch+parse)\n */\nexport interface MidiTrackConfig {\n /** URL to .mid file */\n src?: string;\n /** Pre-parsed MIDI notes (skip fetch+parse) */\n midiNotes?: MidiNoteData[];\n /** Track display name */\n name?: string;\n /** Whether this track is muted */\n muted?: boolean;\n /** Whether this track is soloed */\n soloed?: boolean;\n /** Track volume (default 1.0) */\n volume?: number;\n /** Stereo pan (default 0) */\n pan?: number;\n /** Track color */\n color?: string;\n /** Clip position on timeline in seconds (default 0) */\n startTime?: number;\n /** Override clip duration in seconds (default: derived from last note) */\n duration?: number;\n /** Sample rate for sample-based positioning — pass AudioContext.sampleRate */\n sampleRate: number;\n /** Merge all MIDI tracks from the file into one ClipTrack (default false) */\n flatten?: boolean;\n}\n\nexport interface UseMidiTracksReturn {\n /** Loaded ClipTrack array with midiNotes on clips */\n tracks: ClipTrack[];\n /** Whether any tracks are still loading */\n loading: boolean;\n /** Error message if loading failed, null otherwise */\n error: string | null;\n /** Number of tracks that have finished loading */\n loadedCount: number;\n /** Total number of tracks (known after parsing) */\n totalCount: number;\n}\n\n/**\n * Hook to load MIDI files and convert to ClipTrack format with midiNotes.\n *\n * All tracks are returned at once after loading completes. This ensures\n * all track controls and layout containers appear simultaneously in React,\n * while canvas rendering is deferred to the UI layer for progressive drawing.\n *\n * @example\n * ```typescript\n * const { tracks, loading, error } = useMidiTracks([\n * { src: '/music/song.mid', name: 'Piano' },\n * ]);\n *\n * // Pre-parsed notes (no fetch)\n * const { tracks } = useMidiTracks([\n * { midiNotes: myNotes, name: 'Synth Lead', duration: 30 },\n * ]);\n * ```\n */\nexport function useMidiTracks(configs: MidiTrackConfig[]): UseMidiTracksReturn {\n const [tracks, setTracks] = useState<ClipTrack[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [loadedCount, setLoadedCount] = useState(0);\n const [totalCount, setTotalCount] = useState(configs.length);\n\n // Cache fetched ArrayBuffers by URL so re-parses skip the network.\n const bufferCacheRef = useRef(new Map<string, ArrayBuffer>());\n\n useEffect(() => {\n if (configs.length === 0) {\n setTracks([]);\n setLoading(false);\n setLoadedCount(0);\n setTotalCount(0);\n return;\n }\n\n let cancelled = false;\n const abortController = new AbortController();\n\n const createTrackFromNotes = (\n config: MidiTrackConfig,\n notes: MidiNoteData[],\n trackName: string,\n noteDuration: number,\n midiChannel?: number,\n midiProgram?: number\n ): ClipTrack => {\n const sampleRate = config.sampleRate;\n const clipDuration = config.duration ?? noteDuration;\n\n const clip = createClipFromSeconds({\n startTime: config.startTime ?? 0,\n duration: clipDuration,\n sampleRate,\n sourceDuration: clipDuration,\n midiNotes: notes,\n midiChannel,\n midiProgram,\n name: trackName,\n });\n\n return createTrack({\n name: trackName,\n clips: [clip],\n muted: config.muted ?? false,\n soloed: config.soloed ?? false,\n volume: config.volume ?? 1.0,\n pan: config.pan ?? 0,\n color: config.color,\n });\n };\n\n // Check if all src URLs are already cached — if so, skip loading state\n // to avoid flashing the loading overlay on re-parse-only changes.\n const allCached = configs.every((c) => !c.src || bufferCacheRef.current.has(c.src));\n\n const loadTracks = async () => {\n try {\n if (!allCached) {\n setLoading(true);\n setLoadedCount(0);\n }\n setError(null);\n\n const allTracks: ClipTrack[] = [];\n\n for (const config of configs) {\n if (cancelled) break;\n\n if (config.midiNotes) {\n // Pre-parsed notes — no fetch needed\n const notes = config.midiNotes;\n const duration =\n notes.length > 0 ? Math.max(...notes.map((n) => n.time + n.duration)) : 0;\n const trackName = config.name || 'MIDI Track';\n allTracks.push(createTrackFromNotes(config, notes, trackName, duration));\n } else if (config.src) {\n // Use cached buffer if available, otherwise fetch\n let buffer = bufferCacheRef.current.get(config.src);\n if (!buffer) {\n const response = await fetch(config.src, {\n signal: abortController.signal,\n });\n if (!response.ok) {\n throw new Error(`Failed to fetch ${config.src}: ${response.statusText}`);\n }\n buffer = await response.arrayBuffer();\n bufferCacheRef.current.set(config.src, buffer);\n }\n\n const parsed = parseMidiFile(buffer, {\n flatten: config.flatten,\n });\n for (const parsedTrack of parsed.tracks) {\n if (cancelled) break;\n const trackName = parsedTrack.name;\n allTracks.push(\n createTrackFromNotes(\n config,\n parsedTrack.notes,\n trackName,\n parsedTrack.duration,\n parsedTrack.channel,\n parsedTrack.programNumber\n )\n );\n }\n } else {\n throw new Error('MIDI track config must provide src or midiNotes');\n }\n }\n\n if (!cancelled) {\n setTracks(allTracks);\n setLoadedCount(allTracks.length);\n setTotalCount(allTracks.length);\n setLoading(false);\n }\n } catch (err) {\n if (!cancelled) {\n const errorMessage = err instanceof Error ? err.message : 'Unknown error loading MIDI';\n setError(errorMessage);\n setLoading(false);\n console.error('Error loading MIDI tracks:', err);\n }\n }\n };\n\n loadTracks();\n\n return () => {\n cancelled = true;\n abortController.abort();\n };\n }, [configs]);\n\n return { tracks, loading, error, loadedCount, totalCount };\n}\n"],"mappings":"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","names":["duration"]}
|
package/dist/index.mjs
CHANGED
|
@@ -105,7 +105,7 @@ function useMidiTracks(configs) {
|
|
|
105
105
|
let cancelled = false;
|
|
106
106
|
const abortController = new AbortController();
|
|
107
107
|
const createTrackFromNotes = (config, notes, trackName, noteDuration, midiChannel, midiProgram) => {
|
|
108
|
-
const sampleRate = config.sampleRate
|
|
108
|
+
const sampleRate = config.sampleRate;
|
|
109
109
|
const clipDuration = config.duration ?? noteDuration;
|
|
110
110
|
const clip = createClipFromSeconds({
|
|
111
111
|
startTime: config.startTime ?? 0,
|
package/dist/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/parseMidiFile.ts","../src/useMidiTracks.ts"],"sourcesContent":["import { Midi } from '@tonejs/midi';\nimport type { MidiNoteData } from '@waveform-playlist/core';\n\nexport interface ParsedMidiTrack {\n /** Track name from the MIDI file */\n name: string;\n /** Parsed notes in MidiNoteData format */\n notes: MidiNoteData[];\n /** Duration in seconds (end of last note) */\n duration: number;\n /** MIDI channel (9/10 = percussion) */\n channel: number;\n /** Instrument name from MIDI program change */\n instrument: string;\n /** GM program number (0-127) from MIDI program change event */\n programNumber: number;\n}\n\nexport interface ParsedMidi {\n /** Individual MIDI tracks with their notes */\n tracks: ParsedMidiTrack[];\n /** Total duration in seconds (max of all track durations) */\n duration: number;\n /** Song name from MIDI header */\n name: string;\n /** First tempo in BPM (default 120 if none specified) */\n bpm: number;\n /** Time signature as [numerator, denominator] (default [4, 4]) */\n timeSignature: [number, number];\n}\n\nexport interface ParseMidiOptions {\n /** When true, merges all MIDI tracks into a single ParsedMidiTrack */\n flatten?: boolean;\n}\n\n/**\n * Title-case an instrument name from @tonejs/midi.\n * \"acoustic grand piano\" → \"Acoustic Grand Piano\"\n * \"electric bass (finger)\" → \"Electric Bass (Finger)\"\n */\nfunction titleCase(str: string): string {\n return str.replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\nfunction mapNotes(track: Midi['tracks'][number]): MidiNoteData[] {\n return track.notes.map((note) => ({\n midi: note.midi,\n name: note.name,\n time: note.time,\n duration: note.duration,\n velocity: note.velocity,\n channel: track.channel,\n }));\n}\n\nfunction getTrackDuration(notes: MidiNoteData[]): number {\n if (notes.length === 0) return 0;\n return Math.max(...notes.map((n) => n.time + n.duration));\n}\n\n/**\n * Parse a MIDI file from an ArrayBuffer.\n *\n * Returns structured data with tracks, notes, tempo, and time signature.\n * Notes are already in seconds (tempo-adjusted by @tonejs/midi).\n */\nexport function parseMidiFile(data: ArrayBuffer, options: ParseMidiOptions = {}): ParsedMidi {\n const midi = new Midi(data);\n\n const bpm = midi.header.tempos.length > 0 ? midi.header.tempos[0].bpm : 120;\n\n const timeSig =\n midi.header.timeSignatures.length > 0 ? midi.header.timeSignatures[0].timeSignature : [4, 4];\n\n // Parse all tracks that have notes\n const parsedTracks: ParsedMidiTrack[] = midi.tracks\n .filter((track) => track.notes.length > 0)\n .map((track) => {\n const notes = mapNotes(track);\n const instrument = track.instrument.name;\n const programNumber = track.instrument.number;\n // Channel 9 = GM percussion. Use \"Drums\" for clarity.\n // For melodic tracks: prefer GM instrument name when program is non-default (> 0),\n // since @tonejs/midi defaults to \"acoustic grand piano\" even with no program change.\n // Fall back to track name, then channel number.\n let name: string;\n if (track.channel === 9) {\n name = 'Drums';\n } else if (programNumber > 0) {\n name = titleCase(instrument);\n } else {\n name = track.name.trim() || titleCase(instrument) || `Channel ${track.channel + 1}`;\n }\n return {\n name,\n notes,\n duration: getTrackDuration(notes),\n channel: track.channel,\n instrument,\n programNumber: track.instrument.number,\n };\n });\n\n if (options.flatten && parsedTracks.length > 0) {\n const allNotes = parsedTracks.flatMap((t) => t.notes);\n allNotes.sort((a, b) => a.time - b.time);\n const duration = getTrackDuration(allNotes);\n\n return {\n tracks: [\n {\n name: midi.name || 'MIDI',\n notes: allNotes,\n duration,\n channel: parsedTracks[0].channel,\n instrument: parsedTracks[0].instrument,\n programNumber: parsedTracks[0].programNumber,\n },\n ],\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n }\n\n const duration = parsedTracks.length > 0 ? Math.max(...parsedTracks.map((t) => t.duration)) : 0;\n\n return {\n tracks: parsedTracks,\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n}\n\n/**\n * Fetch and parse a MIDI file from a URL.\n *\n * Supports AbortSignal for cancellation (e.g., component unmount cleanup).\n */\nexport async function parseMidiUrl(\n url: string,\n options: ParseMidiOptions = {},\n signal?: AbortSignal\n): Promise<ParsedMidi> {\n const response = await fetch(url, { signal });\n if (!response.ok) {\n throw new Error(`Failed to fetch MIDI file ${url}: ${response.statusText}`);\n }\n const buffer = await response.arrayBuffer();\n return parseMidiFile(buffer, options);\n}\n","import { useState, useEffect, useRef } from 'react';\nimport {\n type ClipTrack,\n type MidiNoteData,\n createClipFromSeconds,\n createTrack,\n} from '@waveform-playlist/core';\nimport { parseMidiFile } from './parseMidiFile';\n\n/**\n * Configuration for a single MIDI track to load.\n *\n * MIDI data can be provided in two ways:\n * 1. `src` — URL to a .mid file (fetched, parsed with @tonejs/midi)\n * 2. `midiNotes` — Pre-parsed notes (skip fetch+parse)\n */\nexport interface MidiTrackConfig {\n /** URL to .mid file */\n src?: string;\n /** Pre-parsed MIDI notes (skip fetch+parse) */\n midiNotes?: MidiNoteData[];\n /** Track display name */\n name?: string;\n /** Whether this track is muted */\n muted?: boolean;\n /** Whether this track is soloed */\n soloed?: boolean;\n /** Track volume (default 1.0) */\n volume?: number;\n /** Stereo pan (default 0) */\n pan?: number;\n /** Track color */\n color?: string;\n /** Clip position on timeline in seconds (default 0) */\n startTime?: number;\n /** Override clip duration in seconds (default: derived from last note) */\n duration?: number;\n /** Sample rate for sample-based positioning (default 44100) */\n sampleRate?: number;\n /** Merge all MIDI tracks from the file into one ClipTrack (default false) */\n flatten?: boolean;\n}\n\nexport interface UseMidiTracksReturn {\n /** Loaded ClipTrack array with midiNotes on clips */\n tracks: ClipTrack[];\n /** Whether any tracks are still loading */\n loading: boolean;\n /** Error message if loading failed, null otherwise */\n error: string | null;\n /** Number of tracks that have finished loading */\n loadedCount: number;\n /** Total number of tracks (known after parsing) */\n totalCount: number;\n}\n\n/**\n * Hook to load MIDI files and convert to ClipTrack format with midiNotes.\n *\n * All tracks are returned at once after loading completes. This ensures\n * all track controls and layout containers appear simultaneously in React,\n * while canvas rendering is deferred to the UI layer for progressive drawing.\n *\n * @example\n * ```typescript\n * const { tracks, loading, error } = useMidiTracks([\n * { src: '/music/song.mid', name: 'Piano' },\n * ]);\n *\n * // Pre-parsed notes (no fetch)\n * const { tracks } = useMidiTracks([\n * { midiNotes: myNotes, name: 'Synth Lead', duration: 30 },\n * ]);\n * ```\n */\nexport function useMidiTracks(configs: MidiTrackConfig[]): UseMidiTracksReturn {\n const [tracks, setTracks] = useState<ClipTrack[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [loadedCount, setLoadedCount] = useState(0);\n const [totalCount, setTotalCount] = useState(configs.length);\n\n // Cache fetched ArrayBuffers by URL so re-parses skip the network.\n const bufferCacheRef = useRef(new Map<string, ArrayBuffer>());\n\n useEffect(() => {\n if (configs.length === 0) {\n setTracks([]);\n setLoading(false);\n setLoadedCount(0);\n setTotalCount(0);\n return;\n }\n\n let cancelled = false;\n const abortController = new AbortController();\n\n const createTrackFromNotes = (\n config: MidiTrackConfig,\n notes: MidiNoteData[],\n trackName: string,\n noteDuration: number,\n midiChannel?: number,\n midiProgram?: number\n ): ClipTrack => {\n const sampleRate = config.sampleRate ?? 44100;\n const clipDuration = config.duration ?? noteDuration;\n\n const clip = createClipFromSeconds({\n startTime: config.startTime ?? 0,\n duration: clipDuration,\n sampleRate,\n sourceDuration: clipDuration,\n midiNotes: notes,\n midiChannel,\n midiProgram,\n name: trackName,\n });\n\n return createTrack({\n name: trackName,\n clips: [clip],\n muted: config.muted ?? false,\n soloed: config.soloed ?? false,\n volume: config.volume ?? 1.0,\n pan: config.pan ?? 0,\n color: config.color,\n });\n };\n\n // Check if all src URLs are already cached — if so, skip loading state\n // to avoid flashing the loading overlay on re-parse-only changes.\n const allCached = configs.every((c) => !c.src || bufferCacheRef.current.has(c.src));\n\n const loadTracks = async () => {\n try {\n if (!allCached) {\n setLoading(true);\n setLoadedCount(0);\n }\n setError(null);\n\n const allTracks: ClipTrack[] = [];\n\n for (const config of configs) {\n if (cancelled) break;\n\n if (config.midiNotes) {\n // Pre-parsed notes — no fetch needed\n const notes = config.midiNotes;\n const duration =\n notes.length > 0 ? Math.max(...notes.map((n) => n.time + n.duration)) : 0;\n const trackName = config.name || 'MIDI Track';\n allTracks.push(createTrackFromNotes(config, notes, trackName, duration));\n } else if (config.src) {\n // Use cached buffer if available, otherwise fetch\n let buffer = bufferCacheRef.current.get(config.src);\n if (!buffer) {\n const response = await fetch(config.src, {\n signal: abortController.signal,\n });\n if (!response.ok) {\n throw new Error(`Failed to fetch ${config.src}: ${response.statusText}`);\n }\n buffer = await response.arrayBuffer();\n bufferCacheRef.current.set(config.src, buffer);\n }\n\n const parsed = parseMidiFile(buffer, {\n flatten: config.flatten,\n });\n for (const parsedTrack of parsed.tracks) {\n if (cancelled) break;\n const trackName = parsedTrack.name;\n allTracks.push(\n createTrackFromNotes(\n config,\n parsedTrack.notes,\n trackName,\n parsedTrack.duration,\n parsedTrack.channel,\n parsedTrack.programNumber\n )\n );\n }\n } else {\n throw new Error('MIDI track config must provide src or midiNotes');\n }\n }\n\n if (!cancelled) {\n setTracks(allTracks);\n setLoadedCount(allTracks.length);\n setTotalCount(allTracks.length);\n setLoading(false);\n }\n } catch (err) {\n if (!cancelled) {\n const errorMessage = err instanceof Error ? err.message : 'Unknown error loading MIDI';\n setError(errorMessage);\n setLoading(false);\n console.error('Error loading MIDI tracks:', err);\n }\n }\n };\n\n loadTracks();\n\n return () => {\n cancelled = true;\n abortController.abort();\n };\n }, [configs]);\n\n return { tracks, loading, error, loadedCount, totalCount };\n}\n"],"mappings":";AAAA,SAAS,YAAY;AAyCrB,SAAS,UAAU,KAAqB;AACtC,SAAO,IAAI,QAAQ,SAAS,CAAC,MAAM,EAAE,YAAY,CAAC;AACpD;AAEA,SAAS,SAAS,OAA+C;AAC/D,SAAO,MAAM,MAAM,IAAI,CAAC,UAAU;AAAA,IAChC,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,UAAU,KAAK;AAAA,IACf,UAAU,KAAK;AAAA,IACf,SAAS,MAAM;AAAA,EACjB,EAAE;AACJ;AAEA,SAAS,iBAAiB,OAA+B;AACvD,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,SAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC;AAC1D;AAQO,SAAS,cAAc,MAAmB,UAA4B,CAAC,GAAe;AAC3F,QAAM,OAAO,IAAI,KAAK,IAAI;AAE1B,QAAM,MAAM,KAAK,OAAO,OAAO,SAAS,IAAI,KAAK,OAAO,OAAO,CAAC,EAAE,MAAM;AAExE,QAAM,UACJ,KAAK,OAAO,eAAe,SAAS,IAAI,KAAK,OAAO,eAAe,CAAC,EAAE,gBAAgB,CAAC,GAAG,CAAC;AAG7F,QAAM,eAAkC,KAAK,OAC1C,OAAO,CAAC,UAAU,MAAM,MAAM,SAAS,CAAC,EACxC,IAAI,CAAC,UAAU;AACd,UAAM,QAAQ,SAAS,KAAK;AAC5B,UAAM,aAAa,MAAM,WAAW;AACpC,UAAM,gBAAgB,MAAM,WAAW;AAKvC,QAAI;AACJ,QAAI,MAAM,YAAY,GAAG;AACvB,aAAO;AAAA,IACT,WAAW,gBAAgB,GAAG;AAC5B,aAAO,UAAU,UAAU;AAAA,IAC7B,OAAO;AACL,aAAO,MAAM,KAAK,KAAK,KAAK,UAAU,UAAU,KAAK,WAAW,MAAM,UAAU,CAAC;AAAA,IACnF;AACA,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,UAAU,iBAAiB,KAAK;AAAA,MAChC,SAAS,MAAM;AAAA,MACf;AAAA,MACA,eAAe,MAAM,WAAW;AAAA,IAClC;AAAA,EACF,CAAC;AAEH,MAAI,QAAQ,WAAW,aAAa,SAAS,GAAG;AAC9C,UAAM,WAAW,aAAa,QAAQ,CAAC,MAAM,EAAE,KAAK;AACpD,aAAS,KAAK,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI;AACvC,UAAMA,YAAW,iBAAiB,QAAQ;AAE1C,WAAO;AAAA,MACL,QAAQ;AAAA,QACN;AAAA,UACE,MAAM,KAAK,QAAQ;AAAA,UACnB,OAAO;AAAA,UACP,UAAAA;AAAA,UACA,SAAS,aAAa,CAAC,EAAE;AAAA,UACzB,YAAY,aAAa,CAAC,EAAE;AAAA,UAC5B,eAAe,aAAa,CAAC,EAAE;AAAA,QACjC;AAAA,MACF;AAAA,MACA,UAAAA;AAAA,MACA,MAAM,KAAK,QAAQ;AAAA,MACnB;AAAA,MACA,eAAe;AAAA,IACjB;AAAA,EACF;AAEA,QAAM,WAAW,aAAa,SAAS,IAAI,KAAK,IAAI,GAAG,aAAa,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI;AAE9F,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,MAAM,KAAK,QAAQ;AAAA,IACnB;AAAA,IACA,eAAe;AAAA,EACjB;AACF;AAOA,eAAsB,aACpB,KACA,UAA4B,CAAC,GAC7B,QACqB;AACrB,QAAM,WAAW,MAAM,MAAM,KAAK,EAAE,OAAO,CAAC;AAC5C,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,6BAA6B,GAAG,KAAK,SAAS,UAAU,EAAE;AAAA,EAC5E;AACA,QAAM,SAAS,MAAM,SAAS,YAAY;AAC1C,SAAO,cAAc,QAAQ,OAAO;AACtC;;;AC1JA,SAAS,UAAU,WAAW,cAAc;AAC5C;AAAA,EAGE;AAAA,EACA;AAAA,OACK;AAqEA,SAAS,cAAc,SAAiD;AAC7E,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAsB,CAAC,CAAC;AACpD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,IAAI;AAC3C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,IAAI;AACtD,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,CAAC;AAChD,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,QAAQ,MAAM;AAG3D,QAAM,iBAAiB,OAAO,oBAAI,IAAyB,CAAC;AAE5D,YAAU,MAAM;AACd,QAAI,QAAQ,WAAW,GAAG;AACxB,gBAAU,CAAC,CAAC;AACZ,iBAAW,KAAK;AAChB,qBAAe,CAAC;AAChB,oBAAc,CAAC;AACf;AAAA,IACF;AAEA,QAAI,YAAY;AAChB,UAAM,kBAAkB,IAAI,gBAAgB;AAE5C,UAAM,uBAAuB,CAC3B,QACA,OACA,WACA,cACA,aACA,gBACc;AACd,YAAM,aAAa,OAAO,cAAc;AACxC,YAAM,eAAe,OAAO,YAAY;AAExC,YAAM,OAAO,sBAAsB;AAAA,QACjC,WAAW,OAAO,aAAa;AAAA,QAC/B,UAAU;AAAA,QACV;AAAA,QACA,gBAAgB;AAAA,QAChB,WAAW;AAAA,QACX;AAAA,QACA;AAAA,QACA,MAAM;AAAA,MACR,CAAC;AAED,aAAO,YAAY;AAAA,QACjB,MAAM;AAAA,QACN,OAAO,CAAC,IAAI;AAAA,QACZ,OAAO,OAAO,SAAS;AAAA,QACvB,QAAQ,OAAO,UAAU;AAAA,QACzB,QAAQ,OAAO,UAAU;AAAA,QACzB,KAAK,OAAO,OAAO;AAAA,QACnB,OAAO,OAAO;AAAA,MAChB,CAAC;AAAA,IACH;AAIA,UAAM,YAAY,QAAQ,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,eAAe,QAAQ,IAAI,EAAE,GAAG,CAAC;AAElF,UAAM,aAAa,YAAY;AAC7B,UAAI;AACF,YAAI,CAAC,WAAW;AACd,qBAAW,IAAI;AACf,yBAAe,CAAC;AAAA,QAClB;AACA,iBAAS,IAAI;AAEb,cAAM,YAAyB,CAAC;AAEhC,mBAAW,UAAU,SAAS;AAC5B,cAAI,UAAW;AAEf,cAAI,OAAO,WAAW;AAEpB,kBAAM,QAAQ,OAAO;AACrB,kBAAM,WACJ,MAAM,SAAS,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,IAAI;AAC1E,kBAAM,YAAY,OAAO,QAAQ;AACjC,sBAAU,KAAK,qBAAqB,QAAQ,OAAO,WAAW,QAAQ,CAAC;AAAA,UACzE,WAAW,OAAO,KAAK;AAErB,gBAAI,SAAS,eAAe,QAAQ,IAAI,OAAO,GAAG;AAClD,gBAAI,CAAC,QAAQ;AACX,oBAAM,WAAW,MAAM,MAAM,OAAO,KAAK;AAAA,gBACvC,QAAQ,gBAAgB;AAAA,cAC1B,CAAC;AACD,kBAAI,CAAC,SAAS,IAAI;AAChB,sBAAM,IAAI,MAAM,mBAAmB,OAAO,GAAG,KAAK,SAAS,UAAU,EAAE;AAAA,cACzE;AACA,uBAAS,MAAM,SAAS,YAAY;AACpC,6BAAe,QAAQ,IAAI,OAAO,KAAK,MAAM;AAAA,YAC/C;AAEA,kBAAM,SAAS,cAAc,QAAQ;AAAA,cACnC,SAAS,OAAO;AAAA,YAClB,CAAC;AACD,uBAAW,eAAe,OAAO,QAAQ;AACvC,kBAAI,UAAW;AACf,oBAAM,YAAY,YAAY;AAC9B,wBAAU;AAAA,gBACR;AAAA,kBACE;AAAA,kBACA,YAAY;AAAA,kBACZ;AAAA,kBACA,YAAY;AAAA,kBACZ,YAAY;AAAA,kBACZ,YAAY;AAAA,gBACd;AAAA,cACF;AAAA,YACF;AAAA,UACF,OAAO;AACL,kBAAM,IAAI,MAAM,iDAAiD;AAAA,UACnE;AAAA,QACF;AAEA,YAAI,CAAC,WAAW;AACd,oBAAU,SAAS;AACnB,yBAAe,UAAU,MAAM;AAC/B,wBAAc,UAAU,MAAM;AAC9B,qBAAW,KAAK;AAAA,QAClB;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,CAAC,WAAW;AACd,gBAAM,eAAe,eAAe,QAAQ,IAAI,UAAU;AAC1D,mBAAS,YAAY;AACrB,qBAAW,KAAK;AAChB,kBAAQ,MAAM,8BAA8B,GAAG;AAAA,QACjD;AAAA,MACF;AAAA,IACF;AAEA,eAAW;AAEX,WAAO,MAAM;AACX,kBAAY;AACZ,sBAAgB,MAAM;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,SAAO,EAAE,QAAQ,SAAS,OAAO,aAAa,WAAW;AAC3D;","names":["duration"]}
|
|
1
|
+
{"version":3,"sources":["../src/parseMidiFile.ts","../src/useMidiTracks.ts"],"sourcesContent":["import { Midi } from '@tonejs/midi';\nimport type { MidiNoteData } from '@waveform-playlist/core';\n\nexport interface ParsedMidiTrack {\n /** Track name from the MIDI file */\n name: string;\n /** Parsed notes in MidiNoteData format */\n notes: MidiNoteData[];\n /** Duration in seconds (end of last note) */\n duration: number;\n /** MIDI channel (9/10 = percussion) */\n channel: number;\n /** Instrument name from MIDI program change */\n instrument: string;\n /** GM program number (0-127) from MIDI program change event */\n programNumber: number;\n}\n\nexport interface ParsedMidi {\n /** Individual MIDI tracks with their notes */\n tracks: ParsedMidiTrack[];\n /** Total duration in seconds (max of all track durations) */\n duration: number;\n /** Song name from MIDI header */\n name: string;\n /** First tempo in BPM (default 120 if none specified) */\n bpm: number;\n /** Time signature as [numerator, denominator] (default [4, 4]) */\n timeSignature: [number, number];\n}\n\nexport interface ParseMidiOptions {\n /** When true, merges all MIDI tracks into a single ParsedMidiTrack */\n flatten?: boolean;\n}\n\n/**\n * Title-case an instrument name from @tonejs/midi.\n * \"acoustic grand piano\" → \"Acoustic Grand Piano\"\n * \"electric bass (finger)\" → \"Electric Bass (Finger)\"\n */\nfunction titleCase(str: string): string {\n return str.replace(/\\b\\w/g, (c) => c.toUpperCase());\n}\n\nfunction mapNotes(track: Midi['tracks'][number]): MidiNoteData[] {\n return track.notes.map((note) => ({\n midi: note.midi,\n name: note.name,\n time: note.time,\n duration: note.duration,\n velocity: note.velocity,\n channel: track.channel,\n }));\n}\n\nfunction getTrackDuration(notes: MidiNoteData[]): number {\n if (notes.length === 0) return 0;\n return Math.max(...notes.map((n) => n.time + n.duration));\n}\n\n/**\n * Parse a MIDI file from an ArrayBuffer.\n *\n * Returns structured data with tracks, notes, tempo, and time signature.\n * Notes are already in seconds (tempo-adjusted by @tonejs/midi).\n */\nexport function parseMidiFile(data: ArrayBuffer, options: ParseMidiOptions = {}): ParsedMidi {\n const midi = new Midi(data);\n\n const bpm = midi.header.tempos.length > 0 ? midi.header.tempos[0].bpm : 120;\n\n const timeSig =\n midi.header.timeSignatures.length > 0 ? midi.header.timeSignatures[0].timeSignature : [4, 4];\n\n // Parse all tracks that have notes\n const parsedTracks: ParsedMidiTrack[] = midi.tracks\n .filter((track) => track.notes.length > 0)\n .map((track) => {\n const notes = mapNotes(track);\n const instrument = track.instrument.name;\n const programNumber = track.instrument.number;\n // Channel 9 = GM percussion. Use \"Drums\" for clarity.\n // For melodic tracks: prefer GM instrument name when program is non-default (> 0),\n // since @tonejs/midi defaults to \"acoustic grand piano\" even with no program change.\n // Fall back to track name, then channel number.\n let name: string;\n if (track.channel === 9) {\n name = 'Drums';\n } else if (programNumber > 0) {\n name = titleCase(instrument);\n } else {\n name = track.name.trim() || titleCase(instrument) || `Channel ${track.channel + 1}`;\n }\n return {\n name,\n notes,\n duration: getTrackDuration(notes),\n channel: track.channel,\n instrument,\n programNumber: track.instrument.number,\n };\n });\n\n if (options.flatten && parsedTracks.length > 0) {\n const allNotes = parsedTracks.flatMap((t) => t.notes);\n allNotes.sort((a, b) => a.time - b.time);\n const duration = getTrackDuration(allNotes);\n\n return {\n tracks: [\n {\n name: midi.name || 'MIDI',\n notes: allNotes,\n duration,\n channel: parsedTracks[0].channel,\n instrument: parsedTracks[0].instrument,\n programNumber: parsedTracks[0].programNumber,\n },\n ],\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n }\n\n const duration = parsedTracks.length > 0 ? Math.max(...parsedTracks.map((t) => t.duration)) : 0;\n\n return {\n tracks: parsedTracks,\n duration,\n name: midi.name || '',\n bpm,\n timeSignature: timeSig as [number, number],\n };\n}\n\n/**\n * Fetch and parse a MIDI file from a URL.\n *\n * Supports AbortSignal for cancellation (e.g., component unmount cleanup).\n */\nexport async function parseMidiUrl(\n url: string,\n options: ParseMidiOptions = {},\n signal?: AbortSignal\n): Promise<ParsedMidi> {\n const response = await fetch(url, { signal });\n if (!response.ok) {\n throw new Error(`Failed to fetch MIDI file ${url}: ${response.statusText}`);\n }\n const buffer = await response.arrayBuffer();\n return parseMidiFile(buffer, options);\n}\n","import { useState, useEffect, useRef } from 'react';\nimport {\n type ClipTrack,\n type MidiNoteData,\n createClipFromSeconds,\n createTrack,\n} from '@waveform-playlist/core';\nimport { parseMidiFile } from './parseMidiFile';\n\n/**\n * Configuration for a single MIDI track to load.\n *\n * MIDI data can be provided in two ways:\n * 1. `src` — URL to a .mid file (fetched, parsed with @tonejs/midi)\n * 2. `midiNotes` — Pre-parsed notes (skip fetch+parse)\n */\nexport interface MidiTrackConfig {\n /** URL to .mid file */\n src?: string;\n /** Pre-parsed MIDI notes (skip fetch+parse) */\n midiNotes?: MidiNoteData[];\n /** Track display name */\n name?: string;\n /** Whether this track is muted */\n muted?: boolean;\n /** Whether this track is soloed */\n soloed?: boolean;\n /** Track volume (default 1.0) */\n volume?: number;\n /** Stereo pan (default 0) */\n pan?: number;\n /** Track color */\n color?: string;\n /** Clip position on timeline in seconds (default 0) */\n startTime?: number;\n /** Override clip duration in seconds (default: derived from last note) */\n duration?: number;\n /** Sample rate for sample-based positioning — pass AudioContext.sampleRate */\n sampleRate: number;\n /** Merge all MIDI tracks from the file into one ClipTrack (default false) */\n flatten?: boolean;\n}\n\nexport interface UseMidiTracksReturn {\n /** Loaded ClipTrack array with midiNotes on clips */\n tracks: ClipTrack[];\n /** Whether any tracks are still loading */\n loading: boolean;\n /** Error message if loading failed, null otherwise */\n error: string | null;\n /** Number of tracks that have finished loading */\n loadedCount: number;\n /** Total number of tracks (known after parsing) */\n totalCount: number;\n}\n\n/**\n * Hook to load MIDI files and convert to ClipTrack format with midiNotes.\n *\n * All tracks are returned at once after loading completes. This ensures\n * all track controls and layout containers appear simultaneously in React,\n * while canvas rendering is deferred to the UI layer for progressive drawing.\n *\n * @example\n * ```typescript\n * const { tracks, loading, error } = useMidiTracks([\n * { src: '/music/song.mid', name: 'Piano' },\n * ]);\n *\n * // Pre-parsed notes (no fetch)\n * const { tracks } = useMidiTracks([\n * { midiNotes: myNotes, name: 'Synth Lead', duration: 30 },\n * ]);\n * ```\n */\nexport function useMidiTracks(configs: MidiTrackConfig[]): UseMidiTracksReturn {\n const [tracks, setTracks] = useState<ClipTrack[]>([]);\n const [loading, setLoading] = useState(true);\n const [error, setError] = useState<string | null>(null);\n const [loadedCount, setLoadedCount] = useState(0);\n const [totalCount, setTotalCount] = useState(configs.length);\n\n // Cache fetched ArrayBuffers by URL so re-parses skip the network.\n const bufferCacheRef = useRef(new Map<string, ArrayBuffer>());\n\n useEffect(() => {\n if (configs.length === 0) {\n setTracks([]);\n setLoading(false);\n setLoadedCount(0);\n setTotalCount(0);\n return;\n }\n\n let cancelled = false;\n const abortController = new AbortController();\n\n const createTrackFromNotes = (\n config: MidiTrackConfig,\n notes: MidiNoteData[],\n trackName: string,\n noteDuration: number,\n midiChannel?: number,\n midiProgram?: number\n ): ClipTrack => {\n const sampleRate = config.sampleRate;\n const clipDuration = config.duration ?? noteDuration;\n\n const clip = createClipFromSeconds({\n startTime: config.startTime ?? 0,\n duration: clipDuration,\n sampleRate,\n sourceDuration: clipDuration,\n midiNotes: notes,\n midiChannel,\n midiProgram,\n name: trackName,\n });\n\n return createTrack({\n name: trackName,\n clips: [clip],\n muted: config.muted ?? false,\n soloed: config.soloed ?? false,\n volume: config.volume ?? 1.0,\n pan: config.pan ?? 0,\n color: config.color,\n });\n };\n\n // Check if all src URLs are already cached — if so, skip loading state\n // to avoid flashing the loading overlay on re-parse-only changes.\n const allCached = configs.every((c) => !c.src || bufferCacheRef.current.has(c.src));\n\n const loadTracks = async () => {\n try {\n if (!allCached) {\n setLoading(true);\n setLoadedCount(0);\n }\n setError(null);\n\n const allTracks: ClipTrack[] = [];\n\n for (const config of configs) {\n if (cancelled) break;\n\n if (config.midiNotes) {\n // Pre-parsed notes — no fetch needed\n const notes = config.midiNotes;\n const duration =\n notes.length > 0 ? Math.max(...notes.map((n) => n.time + n.duration)) : 0;\n const trackName = config.name || 'MIDI Track';\n allTracks.push(createTrackFromNotes(config, notes, trackName, duration));\n } else if (config.src) {\n // Use cached buffer if available, otherwise fetch\n let buffer = bufferCacheRef.current.get(config.src);\n if (!buffer) {\n const response = await fetch(config.src, {\n signal: abortController.signal,\n });\n if (!response.ok) {\n throw new Error(`Failed to fetch ${config.src}: ${response.statusText}`);\n }\n buffer = await response.arrayBuffer();\n bufferCacheRef.current.set(config.src, buffer);\n }\n\n const parsed = parseMidiFile(buffer, {\n flatten: config.flatten,\n });\n for (const parsedTrack of parsed.tracks) {\n if (cancelled) break;\n const trackName = parsedTrack.name;\n allTracks.push(\n createTrackFromNotes(\n config,\n parsedTrack.notes,\n trackName,\n parsedTrack.duration,\n parsedTrack.channel,\n parsedTrack.programNumber\n )\n );\n }\n } else {\n throw new Error('MIDI track config must provide src or midiNotes');\n }\n }\n\n if (!cancelled) {\n setTracks(allTracks);\n setLoadedCount(allTracks.length);\n setTotalCount(allTracks.length);\n setLoading(false);\n }\n } catch (err) {\n if (!cancelled) {\n const errorMessage = err instanceof Error ? err.message : 'Unknown error loading MIDI';\n setError(errorMessage);\n setLoading(false);\n console.error('Error loading MIDI tracks:', err);\n }\n }\n };\n\n loadTracks();\n\n return () => {\n cancelled = true;\n abortController.abort();\n };\n }, [configs]);\n\n return { tracks, loading, error, loadedCount, totalCount };\n}\n"],"mappings":";AAAA,SAAS,YAAY;AAyCrB,SAAS,UAAU,KAAqB;AACtC,SAAO,IAAI,QAAQ,SAAS,CAAC,MAAM,EAAE,YAAY,CAAC;AACpD;AAEA,SAAS,SAAS,OAA+C;AAC/D,SAAO,MAAM,MAAM,IAAI,CAAC,UAAU;AAAA,IAChC,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,MAAM,KAAK;AAAA,IACX,UAAU,KAAK;AAAA,IACf,UAAU,KAAK;AAAA,IACf,SAAS,MAAM;AAAA,EACjB,EAAE;AACJ;AAEA,SAAS,iBAAiB,OAA+B;AACvD,MAAI,MAAM,WAAW,EAAG,QAAO;AAC/B,SAAO,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC;AAC1D;AAQO,SAAS,cAAc,MAAmB,UAA4B,CAAC,GAAe;AAC3F,QAAM,OAAO,IAAI,KAAK,IAAI;AAE1B,QAAM,MAAM,KAAK,OAAO,OAAO,SAAS,IAAI,KAAK,OAAO,OAAO,CAAC,EAAE,MAAM;AAExE,QAAM,UACJ,KAAK,OAAO,eAAe,SAAS,IAAI,KAAK,OAAO,eAAe,CAAC,EAAE,gBAAgB,CAAC,GAAG,CAAC;AAG7F,QAAM,eAAkC,KAAK,OAC1C,OAAO,CAAC,UAAU,MAAM,MAAM,SAAS,CAAC,EACxC,IAAI,CAAC,UAAU;AACd,UAAM,QAAQ,SAAS,KAAK;AAC5B,UAAM,aAAa,MAAM,WAAW;AACpC,UAAM,gBAAgB,MAAM,WAAW;AAKvC,QAAI;AACJ,QAAI,MAAM,YAAY,GAAG;AACvB,aAAO;AAAA,IACT,WAAW,gBAAgB,GAAG;AAC5B,aAAO,UAAU,UAAU;AAAA,IAC7B,OAAO;AACL,aAAO,MAAM,KAAK,KAAK,KAAK,UAAU,UAAU,KAAK,WAAW,MAAM,UAAU,CAAC;AAAA,IACnF;AACA,WAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA,UAAU,iBAAiB,KAAK;AAAA,MAChC,SAAS,MAAM;AAAA,MACf;AAAA,MACA,eAAe,MAAM,WAAW;AAAA,IAClC;AAAA,EACF,CAAC;AAEH,MAAI,QAAQ,WAAW,aAAa,SAAS,GAAG;AAC9C,UAAM,WAAW,aAAa,QAAQ,CAAC,MAAM,EAAE,KAAK;AACpD,aAAS,KAAK,CAAC,GAAG,MAAM,EAAE,OAAO,EAAE,IAAI;AACvC,UAAMA,YAAW,iBAAiB,QAAQ;AAE1C,WAAO;AAAA,MACL,QAAQ;AAAA,QACN;AAAA,UACE,MAAM,KAAK,QAAQ;AAAA,UACnB,OAAO;AAAA,UACP,UAAAA;AAAA,UACA,SAAS,aAAa,CAAC,EAAE;AAAA,UACzB,YAAY,aAAa,CAAC,EAAE;AAAA,UAC5B,eAAe,aAAa,CAAC,EAAE;AAAA,QACjC;AAAA,MACF;AAAA,MACA,UAAAA;AAAA,MACA,MAAM,KAAK,QAAQ;AAAA,MACnB;AAAA,MACA,eAAe;AAAA,IACjB;AAAA,EACF;AAEA,QAAM,WAAW,aAAa,SAAS,IAAI,KAAK,IAAI,GAAG,aAAa,IAAI,CAAC,MAAM,EAAE,QAAQ,CAAC,IAAI;AAE9F,SAAO;AAAA,IACL,QAAQ;AAAA,IACR;AAAA,IACA,MAAM,KAAK,QAAQ;AAAA,IACnB;AAAA,IACA,eAAe;AAAA,EACjB;AACF;AAOA,eAAsB,aACpB,KACA,UAA4B,CAAC,GAC7B,QACqB;AACrB,QAAM,WAAW,MAAM,MAAM,KAAK,EAAE,OAAO,CAAC;AAC5C,MAAI,CAAC,SAAS,IAAI;AAChB,UAAM,IAAI,MAAM,6BAA6B,GAAG,KAAK,SAAS,UAAU,EAAE;AAAA,EAC5E;AACA,QAAM,SAAS,MAAM,SAAS,YAAY;AAC1C,SAAO,cAAc,QAAQ,OAAO;AACtC;;;AC1JA,SAAS,UAAU,WAAW,cAAc;AAC5C;AAAA,EAGE;AAAA,EACA;AAAA,OACK;AAqEA,SAAS,cAAc,SAAiD;AAC7E,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAsB,CAAC,CAAC;AACpD,QAAM,CAAC,SAAS,UAAU,IAAI,SAAS,IAAI;AAC3C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAwB,IAAI;AACtD,QAAM,CAAC,aAAa,cAAc,IAAI,SAAS,CAAC;AAChD,QAAM,CAAC,YAAY,aAAa,IAAI,SAAS,QAAQ,MAAM;AAG3D,QAAM,iBAAiB,OAAO,oBAAI,IAAyB,CAAC;AAE5D,YAAU,MAAM;AACd,QAAI,QAAQ,WAAW,GAAG;AACxB,gBAAU,CAAC,CAAC;AACZ,iBAAW,KAAK;AAChB,qBAAe,CAAC;AAChB,oBAAc,CAAC;AACf;AAAA,IACF;AAEA,QAAI,YAAY;AAChB,UAAM,kBAAkB,IAAI,gBAAgB;AAE5C,UAAM,uBAAuB,CAC3B,QACA,OACA,WACA,cACA,aACA,gBACc;AACd,YAAM,aAAa,OAAO;AAC1B,YAAM,eAAe,OAAO,YAAY;AAExC,YAAM,OAAO,sBAAsB;AAAA,QACjC,WAAW,OAAO,aAAa;AAAA,QAC/B,UAAU;AAAA,QACV;AAAA,QACA,gBAAgB;AAAA,QAChB,WAAW;AAAA,QACX;AAAA,QACA;AAAA,QACA,MAAM;AAAA,MACR,CAAC;AAED,aAAO,YAAY;AAAA,QACjB,MAAM;AAAA,QACN,OAAO,CAAC,IAAI;AAAA,QACZ,OAAO,OAAO,SAAS;AAAA,QACvB,QAAQ,OAAO,UAAU;AAAA,QACzB,QAAQ,OAAO,UAAU;AAAA,QACzB,KAAK,OAAO,OAAO;AAAA,QACnB,OAAO,OAAO;AAAA,MAChB,CAAC;AAAA,IACH;AAIA,UAAM,YAAY,QAAQ,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,eAAe,QAAQ,IAAI,EAAE,GAAG,CAAC;AAElF,UAAM,aAAa,YAAY;AAC7B,UAAI;AACF,YAAI,CAAC,WAAW;AACd,qBAAW,IAAI;AACf,yBAAe,CAAC;AAAA,QAClB;AACA,iBAAS,IAAI;AAEb,cAAM,YAAyB,CAAC;AAEhC,mBAAW,UAAU,SAAS;AAC5B,cAAI,UAAW;AAEf,cAAI,OAAO,WAAW;AAEpB,kBAAM,QAAQ,OAAO;AACrB,kBAAM,WACJ,MAAM,SAAS,IAAI,KAAK,IAAI,GAAG,MAAM,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,QAAQ,CAAC,IAAI;AAC1E,kBAAM,YAAY,OAAO,QAAQ;AACjC,sBAAU,KAAK,qBAAqB,QAAQ,OAAO,WAAW,QAAQ,CAAC;AAAA,UACzE,WAAW,OAAO,KAAK;AAErB,gBAAI,SAAS,eAAe,QAAQ,IAAI,OAAO,GAAG;AAClD,gBAAI,CAAC,QAAQ;AACX,oBAAM,WAAW,MAAM,MAAM,OAAO,KAAK;AAAA,gBACvC,QAAQ,gBAAgB;AAAA,cAC1B,CAAC;AACD,kBAAI,CAAC,SAAS,IAAI;AAChB,sBAAM,IAAI,MAAM,mBAAmB,OAAO,GAAG,KAAK,SAAS,UAAU,EAAE;AAAA,cACzE;AACA,uBAAS,MAAM,SAAS,YAAY;AACpC,6BAAe,QAAQ,IAAI,OAAO,KAAK,MAAM;AAAA,YAC/C;AAEA,kBAAM,SAAS,cAAc,QAAQ;AAAA,cACnC,SAAS,OAAO;AAAA,YAClB,CAAC;AACD,uBAAW,eAAe,OAAO,QAAQ;AACvC,kBAAI,UAAW;AACf,oBAAM,YAAY,YAAY;AAC9B,wBAAU;AAAA,gBACR;AAAA,kBACE;AAAA,kBACA,YAAY;AAAA,kBACZ;AAAA,kBACA,YAAY;AAAA,kBACZ,YAAY;AAAA,kBACZ,YAAY;AAAA,gBACd;AAAA,cACF;AAAA,YACF;AAAA,UACF,OAAO;AACL,kBAAM,IAAI,MAAM,iDAAiD;AAAA,UACnE;AAAA,QACF;AAEA,YAAI,CAAC,WAAW;AACd,oBAAU,SAAS;AACnB,yBAAe,UAAU,MAAM;AAC/B,wBAAc,UAAU,MAAM;AAC9B,qBAAW,KAAK;AAAA,QAClB;AAAA,MACF,SAAS,KAAK;AACZ,YAAI,CAAC,WAAW;AACd,gBAAM,eAAe,eAAe,QAAQ,IAAI,UAAU;AAC1D,mBAAS,YAAY;AACrB,qBAAW,KAAK;AAChB,kBAAQ,MAAM,8BAA8B,GAAG;AAAA,QACjD;AAAA,MACF;AAAA,IACF;AAEA,eAAW;AAEX,WAAO,MAAM;AACX,kBAAY;AACZ,sBAAgB,MAAM;AAAA,IACxB;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,SAAO,EAAE,QAAQ,SAAS,OAAO,aAAa,WAAW;AAC3D;","names":["duration"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waveform-playlist/midi",
|
|
3
|
-
"version": "10.
|
|
3
|
+
"version": "10.4.0",
|
|
4
4
|
"description": "MIDI file loading and parsing for waveform-playlist",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"module": "./dist/index.mjs",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
],
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@tonejs/midi": "^2.0.28",
|
|
39
|
-
"@waveform-playlist/core": "10.
|
|
39
|
+
"@waveform-playlist/core": "10.4.0"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@testing-library/react": "^16.0.0",
|