Merge security and parse fixes
- Add SSRF protection (block localhost/private IPs) - Make MIDI parsing deterministic (real track analysis) - Improve BitMidi outage UX (clear 503 + retry message) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
+55
@@ -0,0 +1,55 @@
|
|||||||
|
# Handoff: Security + Search Resilience
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This branch contains local-only changes for review by the dev team. No deployment actions were taken.
|
||||||
|
|
||||||
|
Branch: `codex/security-and-parse-fixes`
|
||||||
|
|
||||||
|
## Change Sets
|
||||||
|
|
||||||
|
### 1) Backend security + deterministic metadata parsing
|
||||||
|
|
||||||
|
- `server/src/services/MIDIFetchService.ts`
|
||||||
|
- Added URL target validation to reduce SSRF risk:
|
||||||
|
- only `http/https`
|
||||||
|
- blocks embedded credentials
|
||||||
|
- blocks `localhost`/local hostnames
|
||||||
|
- blocks private/local/multicast IP targets (direct or DNS-resolved)
|
||||||
|
- `server/src/services/MIDIParseService.ts`
|
||||||
|
- Replaced placeholder/random parsing with deterministic MIDI track parsing.
|
||||||
|
- Extracts stable metadata: note count, basic track info, tempo/time-signature hints, duration estimate from ticks.
|
||||||
|
|
||||||
|
### 2) BitMidi outage UX (clear retry message)
|
||||||
|
|
||||||
|
- `server/src/adapters/BitMidiAdapter.ts`
|
||||||
|
- Propagates adapter failures instead of silently returning empty results.
|
||||||
|
- `server/src/services/MIDISearchService.ts`
|
||||||
|
- Distinguishes "all providers failed" from "no matches".
|
||||||
|
- `server/src/server.ts`
|
||||||
|
- Returns `503` + explicit message when MIDI source is unavailable.
|
||||||
|
- `src/services/MIDIService.ts`
|
||||||
|
- Surfaces backend JSON error messages to the frontend.
|
||||||
|
|
||||||
|
## Build/Checks Run Locally
|
||||||
|
|
||||||
|
- Frontend:
|
||||||
|
- `npm run typecheck` (pass)
|
||||||
|
- `npm run build` (pass)
|
||||||
|
- Backend:
|
||||||
|
- `npm run build` (pass)
|
||||||
|
|
||||||
|
## Behavior to Verify in Staging
|
||||||
|
|
||||||
|
1. Successful flow:
|
||||||
|
- search song -> results load -> select result -> preview/generate works.
|
||||||
|
2. Source outage flow:
|
||||||
|
- when BitMidi fails upstream, user sees:
|
||||||
|
- `BitMidi is temporarily unavailable. Please try again in a minute.`
|
||||||
|
3. SSRF guard:
|
||||||
|
- blocked URL example returns `403`:
|
||||||
|
- `/api/midi/fetch?u=http://127.0.0.1:3001/health`
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Cherry-pick/merge by commit. If needed, revert individual commits cleanly.
|
||||||
@@ -22,7 +22,7 @@ export class BitMidiAdapter implements SearchAdapter {
|
|||||||
return this.parseSearchResults(html, query);
|
return this.parseSearchResults(html, query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('BitMidi search error:', error);
|
console.error('BitMidi search error:', error);
|
||||||
return [];
|
throw error instanceof Error ? error : new Error('BitMidi search failed');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,9 @@ app.get('/api/midi/search', async (req, res) => {
|
|||||||
res.json({ results, count: results.length });
|
res.json({ results, count: results.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search error:', error);
|
console.error('Search error:', error);
|
||||||
|
if (error instanceof Error && error.message === 'MIDI_SOURCE_UNAVAILABLE') {
|
||||||
|
return res.status(503).json({ error: 'BitMidi is temporarily unavailable. Please try again in a minute.' });
|
||||||
|
}
|
||||||
res.status(500).json({ error: 'Search failed' });
|
res.status(500).json({ error: 'Search failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -284,7 +287,9 @@ app.get('/api/midi/fetch', async (req, res) => {
|
|||||||
res.setHeader('Content-Length', result.data!.byteLength);
|
res.setHeader('Content-Length', result.data!.byteLength);
|
||||||
res.send(Buffer.from(result.data!));
|
res.send(Buffer.from(result.data!));
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: result.error });
|
const error = String(result.error || 'Fetch failed');
|
||||||
|
const blocked = /blocked|not allowed/i.test(error);
|
||||||
|
res.status(blocked ? 403 : 404).json({ error });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch error:', error);
|
console.error('Fetch error:', error);
|
||||||
@@ -307,7 +312,9 @@ app.get('/api/midi/parse', async (req, res) => {
|
|||||||
const metadata = parseService.parseMIDI(result.data);
|
const metadata = parseService.parseMIDI(result.data);
|
||||||
res.json(metadata);
|
res.json(metadata);
|
||||||
} else {
|
} else {
|
||||||
res.status(404).json({ error: result.error || 'Failed to fetch MIDI' });
|
const error = String(result.error || 'Failed to fetch MIDI');
|
||||||
|
const blocked = /blocked|not allowed/i.test(error);
|
||||||
|
res.status(blocked ? 403 : 404).json({ error });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Parse error:', error);
|
console.error('Parse error:', error);
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import fs from 'fs/promises';
|
import fs from 'fs/promises';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
|
import dns from 'node:dns/promises';
|
||||||
|
import net from 'node:net';
|
||||||
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
import { ScoreUtils } from '../utils/ScoreUtils.js';
|
||||||
import { SimpleMIDI } from '../utils/SimpleMIDI.js';
|
import { SimpleMIDI } from '../utils/SimpleMIDI.js';
|
||||||
import type { CacheEntry } from '../types.js';
|
import type { CacheEntry } from '../types.js';
|
||||||
@@ -28,6 +30,11 @@ export class MIDIFetchService {
|
|||||||
return { success: true, data: syntheticBuffer };
|
return { success: true, data: syntheticBuffer };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const target = await this.validateTargetUrl(url);
|
||||||
|
if (!target.valid) {
|
||||||
|
return { success: false, error: target.error };
|
||||||
|
}
|
||||||
|
|
||||||
// Check cache first
|
// Check cache first
|
||||||
const hash = this.hashUrl(url);
|
const hash = this.hashUrl(url);
|
||||||
const cached = await this.getCached(hash);
|
const cached = await this.getCached(hash);
|
||||||
@@ -101,6 +108,103 @@ export class MIDIFetchService {
|
|||||||
return { valid: true };
|
return { valid: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async validateTargetUrl(rawUrl: string): Promise<{ valid: boolean; error?: string }> {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Invalid URL' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
return { valid: false, error: 'Only http/https URLs are allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
return { valid: false, error: 'URLs with embedded credentials are not allowed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const hostname = parsed.hostname.trim().toLowerCase();
|
||||||
|
if (!hostname) {
|
||||||
|
return { valid: false, error: 'Missing hostname' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isBlockedHostname(hostname)) {
|
||||||
|
return { valid: false, error: 'Blocked hostname' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// If hostname is a literal IP, validate directly.
|
||||||
|
if (net.isIP(hostname)) {
|
||||||
|
if (this.isPrivateOrLocalIp(hostname)) {
|
||||||
|
return { valid: false, error: 'Blocked target IP' };
|
||||||
|
}
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resolved = await dns.lookup(hostname, { all: true });
|
||||||
|
if (resolved.length === 0) {
|
||||||
|
return { valid: false, error: 'Unable to resolve hostname' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require every resolved IP to be public routable.
|
||||||
|
for (const entry of resolved) {
|
||||||
|
if (this.isPrivateOrLocalIp(entry.address)) {
|
||||||
|
return { valid: false, error: 'Blocked target IP' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { valid: false, error: 'Unable to resolve hostname' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
private isBlockedHostname(hostname: string): boolean {
|
||||||
|
return hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local');
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPrivateOrLocalIp(ip: string): boolean {
|
||||||
|
if (net.isIPv4(ip)) {
|
||||||
|
return this.isPrivateOrLocalIPv4(ip);
|
||||||
|
}
|
||||||
|
if (net.isIPv6(ip)) {
|
||||||
|
return this.isPrivateOrLocalIPv6(ip);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPrivateOrLocalIPv4(ip: string): boolean {
|
||||||
|
const parts = ip.split('.').map(Number);
|
||||||
|
if (parts.length !== 4 || parts.some((n) => Number.isNaN(n) || n < 0 || n > 255)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [a, b] = parts;
|
||||||
|
|
||||||
|
if (a === 10) return true; // 10.0.0.0/8
|
||||||
|
if (a === 127) return true; // 127.0.0.0/8 loopback
|
||||||
|
if (a === 0) return true; // 0.0.0.0/8 "this host"
|
||||||
|
if (a === 169 && b === 254) return true; // 169.254.0.0/16 link-local
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
|
||||||
|
if (a === 192 && b === 168) return true; // 192.168.0.0/16
|
||||||
|
if (a >= 224) return true; // multicast/reserved
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private isPrivateOrLocalIPv6(ip: string): boolean {
|
||||||
|
const normalized = ip.toLowerCase();
|
||||||
|
if (normalized === '::1') return true; // loopback
|
||||||
|
if (normalized === '::') return true; // unspecified
|
||||||
|
if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true; // ULA fc00::/7
|
||||||
|
if (normalized.startsWith('fe8') || normalized.startsWith('fe9') || normalized.startsWith('fea') || normalized.startsWith('feb')) {
|
||||||
|
return true; // link-local fe80::/10
|
||||||
|
}
|
||||||
|
if (normalized.startsWith('ff')) return true; // multicast ff00::/8
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private hashUrl(url: string): string {
|
private hashUrl(url: string): string {
|
||||||
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
|
return crypto.createHash('sha256').update(url).digest('hex').slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,22 +3,23 @@ import type { ParsedMIDIInfo, TrackInfo } from '../types.js';
|
|||||||
export class MIDIParseService {
|
export class MIDIParseService {
|
||||||
parseMIDI(buffer: ArrayBuffer): ParsedMIDIInfo {
|
parseMIDI(buffer: ArrayBuffer): ParsedMIDIInfo {
|
||||||
try {
|
try {
|
||||||
// Basic MIDI parsing - simplified for MVP
|
|
||||||
const view = new DataView(buffer);
|
const view = new DataView(buffer);
|
||||||
const issues: string[] = [];
|
const issues: string[] = [];
|
||||||
|
|
||||||
// Check header
|
|
||||||
if (buffer.byteLength < 14) {
|
if (buffer.byteLength < 14) {
|
||||||
throw new Error('File too small');
|
throw new Error('File too small');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read MIDI header
|
|
||||||
const headerType = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
|
const headerType = String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3));
|
||||||
if (headerType !== 'MThd') {
|
if (headerType !== 'MThd') {
|
||||||
throw new Error('Invalid MIDI header');
|
throw new Error('Invalid MIDI header');
|
||||||
}
|
}
|
||||||
|
|
||||||
const format = view.getUint16(8);
|
const headerLength = view.getUint32(4);
|
||||||
|
if (headerLength < 6) {
|
||||||
|
throw new Error('Invalid MIDI header length');
|
||||||
|
}
|
||||||
|
|
||||||
const trackCount = view.getUint16(10);
|
const trackCount = view.getUint16(10);
|
||||||
const timeDivision = view.getUint16(12);
|
const timeDivision = view.getUint16(12);
|
||||||
|
|
||||||
@@ -26,25 +27,64 @@ export class MIDIParseService {
|
|||||||
issues.push('No tracks found');
|
issues.push('No tracks found');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Estimate duration and tempo (simplified)
|
let offset = 8 + headerLength;
|
||||||
const durationSec = this.estimateDuration(view, buffer.byteLength);
|
let parsedTracks = 0;
|
||||||
const tempoBpm = this.estimateTempo(view, timeDivision);
|
let maxTick = 0;
|
||||||
|
let tempoMicrosPerQuarter = 500000; // 120 BPM default
|
||||||
|
let timeSig: { num: number; den: number } | undefined;
|
||||||
|
|
||||||
// Create mock track info (real implementation would parse each track)
|
|
||||||
const tracks: TrackInfo[] = [];
|
const tracks: TrackInfo[] = [];
|
||||||
for (let i = 0; i < Math.min(trackCount, 16); i++) {
|
for (let i = 0; i < trackCount && offset + 8 <= buffer.byteLength; i++) {
|
||||||
|
const chunkType = String.fromCharCode(
|
||||||
|
view.getUint8(offset),
|
||||||
|
view.getUint8(offset + 1),
|
||||||
|
view.getUint8(offset + 2),
|
||||||
|
view.getUint8(offset + 3)
|
||||||
|
);
|
||||||
|
const chunkLength = view.getUint32(offset + 4);
|
||||||
|
const chunkStart = offset + 8;
|
||||||
|
const chunkEnd = chunkStart + chunkLength;
|
||||||
|
|
||||||
|
if (chunkType !== 'MTrk') {
|
||||||
|
issues.push(`Unexpected chunk type "${chunkType}" at track ${i + 1}`);
|
||||||
|
offset = chunkEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (chunkEnd > buffer.byteLength) {
|
||||||
|
issues.push(`Track ${i + 1} exceeds file length`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = this.parseTrack(view, chunkStart, chunkEnd);
|
||||||
|
parsedTracks++;
|
||||||
|
maxTick = Math.max(maxTick, parsed.trackEndTick);
|
||||||
|
if (parsed.firstTempoMicrosPerQuarter && tempoMicrosPerQuarter === 500000) {
|
||||||
|
tempoMicrosPerQuarter = parsed.firstTempoMicrosPerQuarter;
|
||||||
|
}
|
||||||
|
if (!timeSig && parsed.timeSig) {
|
||||||
|
timeSig = parsed.timeSig;
|
||||||
|
}
|
||||||
|
|
||||||
tracks.push({
|
tracks.push({
|
||||||
id: i,
|
id: i,
|
||||||
name: `Track ${i + 1}`,
|
name: parsed.name || `Track ${i + 1}`,
|
||||||
noteCount: Math.floor(Math.random() * 100) + 10, // Placeholder
|
program: parsed.program,
|
||||||
channel: i < 9 ? i : i + 1, // Skip channel 10 (drums)
|
noteCount: parsed.noteCount,
|
||||||
register: i === 0 ? 'low' : i < trackCount / 2 ? 'mid' : 'high'
|
channel: parsed.channel,
|
||||||
|
register: this.pitchToRegister(parsed.avgPitch)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
offset = chunkEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedTracks < trackCount) {
|
||||||
|
issues.push(`Parsed ${parsedTracks}/${trackCount} tracks`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalNotes = tracks.reduce((sum, t) => sum + t.noteCount, 0);
|
const totalNotes = tracks.reduce((sum, t) => sum + t.noteCount, 0);
|
||||||
|
const tempoBpm = Math.max(1, Math.round((60_000_000 / tempoMicrosPerQuarter) * 100) / 100);
|
||||||
|
const durationSec = this.estimateDurationSec(maxTick, timeDivision, tempoBpm);
|
||||||
|
|
||||||
// Add quality issues
|
|
||||||
if (durationSec < 20) issues.push('Very short duration');
|
if (durationSec < 20) issues.push('Very short duration');
|
||||||
if (durationSec > 600) issues.push('Very long duration');
|
if (durationSec > 600) issues.push('Very long duration');
|
||||||
if (totalNotes < 50) issues.push('Very few notes');
|
if (totalNotes < 50) issues.push('Very few notes');
|
||||||
@@ -53,7 +93,7 @@ export class MIDIParseService {
|
|||||||
return {
|
return {
|
||||||
durationSec,
|
durationSec,
|
||||||
tempoBpm,
|
tempoBpm,
|
||||||
timeSig: { num: 4, den: 4 }, // Default assumption
|
timeSig: timeSig || { num: 4, den: 4 },
|
||||||
tracks,
|
tracks,
|
||||||
noteCount: totalNotes,
|
noteCount: totalNotes,
|
||||||
issues
|
issues
|
||||||
@@ -62,25 +102,159 @@ export class MIDIParseService {
|
|||||||
throw new Error(`MIDI parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
throw new Error(`MIDI parsing failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
private parseTrack(view: DataView, start: number, end: number): {
|
||||||
|
noteCount: number;
|
||||||
|
trackEndTick: number;
|
||||||
|
firstTempoMicrosPerQuarter?: number;
|
||||||
|
timeSig?: { num: number; den: number };
|
||||||
|
name?: string;
|
||||||
|
program?: number;
|
||||||
|
channel?: number;
|
||||||
|
avgPitch: number;
|
||||||
|
} {
|
||||||
|
let pos = start;
|
||||||
|
let runningStatus = 0;
|
||||||
|
let tick = 0;
|
||||||
|
let firstTempoMicrosPerQuarter: number | undefined;
|
||||||
|
let timeSig: { num: number; den: number } | undefined;
|
||||||
|
let name: string | undefined;
|
||||||
|
let program: number | undefined;
|
||||||
|
let channel: number | undefined;
|
||||||
|
let noteCount = 0;
|
||||||
|
let pitchSum = 0;
|
||||||
|
|
||||||
private estimateDuration(view: DataView, totalSize: number): number {
|
while (pos < end) {
|
||||||
// Very rough estimation based on file size
|
const delta = this.readVarLen(view, pos, end);
|
||||||
const sizeKB = totalSize / 1024;
|
pos = delta.next;
|
||||||
if (sizeKB < 10) return 30;
|
tick += delta.value;
|
||||||
if (sizeKB < 50) return 120;
|
|
||||||
if (sizeKB < 200) return 240;
|
if (pos >= end) break;
|
||||||
return 300;
|
|
||||||
|
let status = view.getUint8(pos);
|
||||||
|
if (status < 0x80) {
|
||||||
|
if (runningStatus === 0) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
status = runningStatus;
|
||||||
private estimateTempo(view: DataView, timeDivision: number): number {
|
|
||||||
// Default tempo estimation
|
|
||||||
if (timeDivision & 0x8000) {
|
|
||||||
// SMPTE format
|
|
||||||
return 120;
|
|
||||||
} else {
|
} else {
|
||||||
// Ticks per quarter note format
|
pos++;
|
||||||
// Look for tempo meta events (would require full parsing)
|
runningStatus = status;
|
||||||
return 120; // Default
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Meta event
|
||||||
|
if (status === 0xff) {
|
||||||
|
if (pos >= end) break;
|
||||||
|
const metaType = view.getUint8(pos++);
|
||||||
|
const len = this.readVarLen(view, pos, end);
|
||||||
|
pos = len.next;
|
||||||
|
const dataStart = pos;
|
||||||
|
const dataEnd = Math.min(end, pos + len.value);
|
||||||
|
|
||||||
|
if (metaType === 0x03 && !name) {
|
||||||
|
name = this.decodeAscii(view, dataStart, dataEnd);
|
||||||
|
} else if (metaType === 0x51 && len.value === 3 && firstTempoMicrosPerQuarter === undefined) {
|
||||||
|
firstTempoMicrosPerQuarter =
|
||||||
|
(view.getUint8(dataStart) << 16) |
|
||||||
|
(view.getUint8(dataStart + 1) << 8) |
|
||||||
|
view.getUint8(dataStart + 2);
|
||||||
|
} else if (metaType === 0x58 && len.value >= 2 && !timeSig) {
|
||||||
|
const num = view.getUint8(dataStart);
|
||||||
|
const denPow = view.getUint8(dataStart + 1);
|
||||||
|
timeSig = { num, den: Math.pow(2, denPow) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pos = dataEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysEx (skip payload)
|
||||||
|
if (status === 0xf0 || status === 0xf7) {
|
||||||
|
const len = this.readVarLen(view, pos, end);
|
||||||
|
pos = Math.min(end, len.next + len.value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const eventType = status & 0xf0;
|
||||||
|
const eventChannel = status & 0x0f;
|
||||||
|
|
||||||
|
if (channel === undefined) {
|
||||||
|
channel = eventChannel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTwoDataBytes =
|
||||||
|
eventType === 0x80 ||
|
||||||
|
eventType === 0x90 ||
|
||||||
|
eventType === 0xa0 ||
|
||||||
|
eventType === 0xb0 ||
|
||||||
|
eventType === 0xe0;
|
||||||
|
|
||||||
|
if (hasTwoDataBytes) {
|
||||||
|
if (pos + 1 >= end) break;
|
||||||
|
const data1 = view.getUint8(pos++);
|
||||||
|
const data2 = view.getUint8(pos++);
|
||||||
|
|
||||||
|
// Program-like estimate for melodic tracks
|
||||||
|
if (eventType === 0x90 && data2 > 0) {
|
||||||
|
noteCount++;
|
||||||
|
pitchSum += data1;
|
||||||
|
}
|
||||||
|
} else if (eventType === 0xc0 || eventType === 0xd0) {
|
||||||
|
if (pos >= end) break;
|
||||||
|
const data = view.getUint8(pos++);
|
||||||
|
if (eventType === 0xc0 && program === undefined) {
|
||||||
|
program = data;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown status; stop to avoid desync.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
noteCount,
|
||||||
|
trackEndTick: tick,
|
||||||
|
firstTempoMicrosPerQuarter,
|
||||||
|
timeSig,
|
||||||
|
name,
|
||||||
|
program,
|
||||||
|
channel,
|
||||||
|
avgPitch: noteCount > 0 ? pitchSum / noteCount : 60,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private readVarLen(view: DataView, pos: number, end: number): { value: number; next: number } {
|
||||||
|
let value = 0;
|
||||||
|
let cursor = pos;
|
||||||
|
for (let i = 0; i < 4 && cursor < end; i++) {
|
||||||
|
const b = view.getUint8(cursor++);
|
||||||
|
value = (value << 7) | (b & 0x7f);
|
||||||
|
if ((b & 0x80) === 0) break;
|
||||||
|
}
|
||||||
|
return { value, next: cursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
private decodeAscii(view: DataView, start: number, end: number): string {
|
||||||
|
let out = '';
|
||||||
|
for (let i = start; i < end; i++) {
|
||||||
|
const code = view.getUint8(i);
|
||||||
|
if (code >= 32 && code <= 126) out += String.fromCharCode(code);
|
||||||
|
}
|
||||||
|
return out.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private pitchToRegister(avgPitch: number): 'low' | 'mid' | 'high' {
|
||||||
|
if (avgPitch < 48) return 'low';
|
||||||
|
if (avgPitch < 72) return 'mid';
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
private estimateDurationSec(maxTick: number, timeDivision: number, tempoBpm: number): number {
|
||||||
|
// SMPTE time format is more complex; use conservative fallback.
|
||||||
|
if ((timeDivision & 0x8000) !== 0) {
|
||||||
|
return Math.max(0, Math.round((maxTick / 1000) * 100) / 100);
|
||||||
|
}
|
||||||
|
const ticksPerQuarter = timeDivision || 480;
|
||||||
|
const seconds = (maxTick / ticksPerQuarter) * (60 / tempoBpm);
|
||||||
|
return Math.max(0, Math.round(seconds * 100) / 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,23 +19,29 @@ export class MIDISearchService {
|
|||||||
try {
|
try {
|
||||||
const results = await adapter.search(query);
|
const results = await adapter.search(query);
|
||||||
console.log(`${adapter.name}: Found ${results.length} results`);
|
console.log(`${adapter.name}: Found ${results.length} results`);
|
||||||
return results;
|
return { ok: true, results } as const;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${adapter.name} search failed:`, error);
|
console.error(`${adapter.name} search failed:`, error);
|
||||||
return [];
|
return { ok: false, results: [] as MIDICandidate[] } as const;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const results = await Promise.all(searchPromises);
|
const results = await Promise.all(searchPromises);
|
||||||
|
const allFailed = results.length > 0 && results.every(r => !r.ok);
|
||||||
|
|
||||||
// Combine and deduplicate results
|
// Combine and deduplicate results
|
||||||
for (const adapterResults of results) {
|
for (const adapterResults of results) {
|
||||||
allResults.push(...adapterResults);
|
allResults.push(...adapterResults.results);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove duplicates (same MIDI URL)
|
// Remove duplicates (same MIDI URL)
|
||||||
const uniqueResults = this.deduplicateResults(allResults);
|
const uniqueResults = this.deduplicateResults(allResults);
|
||||||
|
|
||||||
|
// Distinguish provider outage from "no matches found"
|
||||||
|
if (uniqueResults.length === 0 && allFailed) {
|
||||||
|
throw new Error('MIDI_SOURCE_UNAVAILABLE');
|
||||||
|
}
|
||||||
|
|
||||||
// Sort by confidence score
|
// Sort by confidence score
|
||||||
uniqueResults.sort((a, b) => b.confidence - a.confidence);
|
uniqueResults.sort((a, b) => b.confidence - a.confidence);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
export interface MIDICandidate {
|
export interface MIDICandidate {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
source: 'bitmidi' | 'dongrays' | 'mock';
|
source: 'bitmidi' | 'dongrays' | 'freemidi' | 'mock';
|
||||||
pageUrl: string;
|
pageUrl: string;
|
||||||
midiUrl: string;
|
midiUrl: string;
|
||||||
confidence: number;
|
confidence: number;
|
||||||
|
|||||||
@@ -78,7 +78,16 @@ export class MIDIService {
|
|||||||
|
|
||||||
async search(query: string): Promise<MIDISearchResult[]> {
|
async search(query: string): Promise<MIDISearchResult[]> {
|
||||||
const response = await this.fetchWithRetry(`${this.baseUrl}/api/midi/search?q=${encodeURIComponent(query)}`);
|
const response = await this.fetchWithRetry(`${this.baseUrl}/api/midi/search?q=${encodeURIComponent(query)}`);
|
||||||
if (!response.ok) throw new Error(`Search failed: ${response.status}`);
|
if (!response.ok) {
|
||||||
|
let message = `Search failed: ${response.status}`;
|
||||||
|
try {
|
||||||
|
const body = await response.json() as { error?: string };
|
||||||
|
if (body?.error) message = body.error;
|
||||||
|
} catch {
|
||||||
|
// ignore JSON parse errors and keep generic message
|
||||||
|
}
|
||||||
|
throw new Error(message);
|
||||||
|
}
|
||||||
const data: MIDISearchResponse = await response.json();
|
const data: MIDISearchResponse = await response.json();
|
||||||
return data.results;
|
return data.results;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user