Major role mapping and synthesis improvements

Role Mapping Rewrite:
- Replace naive hard thresholds with scored feature analysis
- Extract comprehensive track features (polyphony, repetition, phrase continuity)
- Add melody vs accompaniment separation logic
- Competitive role allocation with fallback handling
- Support for 6 roles: bass, drone, ostinato, texture, accents, melody

Chord Detection & Polyphony:
- Extract chord events from simultaneous notes (50ms window)
- Add ChordEvent type with multiple pitches
- SynthesisEngine supports both single notes and chords
- Drone/texture layers use chords, others remain monophonic

Feature Extraction:
- medianPitch, pitchRange, noteDensity analysis
- polyphonyRatio calculation via note overlap detection
- repetitionScore using 4-note pattern matching
- phraseContinuity detection via stepwise motion
- register classification (low/mid/high)

This should dramatically improve "sounds like the song" recognition
by using actual musical features instead of arbitrary cutoffs.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
b1rdmania
2025-12-17 21:00:33 +00:00
parent 11c894bd91
commit 2d4c748d4c
7 changed files with 5361 additions and 129 deletions
+264 -38
View File
@@ -1,25 +1,273 @@
import type { NoteEvent, StructuralFeatures, RoleAssignment, Role } from '../types';
import type { NoteEvent, ChordEvent, StructuralFeatures, RoleAssignment, Role, TrackFeatures } from '../types';
export class RoleMapper {
assignRoles(features: StructuralFeatures, events: NoteEvent[]): RoleAssignment[] {
const assignments: RoleAssignment[] = [];
const trackEvents = this.groupEventsByTrack(events);
const roleScores = new Map<number, Map<Role, number>>();
// Calculate features and scores for each track
for (const [trackId, trackNotes] of trackEvents) {
const role = this.determineRole(trackNotes, features);
const confidence = this.calculateConfidence(trackNotes, role);
const trackFeatures = this.extractTrackFeatures(trackNotes);
const scores = this.calculateRoleScores(trackFeatures, features);
features.trackFeatures.set(trackId, trackFeatures);
roleScores.set(trackId, scores);
}
// Assign roles using competitive allocation
const assignedRoles = this.allocateRoles(roleScores, trackEvents);
// Create assignments with chord extraction
for (const [trackId, role] of assignedRoles) {
const trackNotes = trackEvents.get(trackId)!;
const trackFeatures = features.trackFeatures.get(trackId)!;
const chords = this.extractChords(trackNotes);
const confidence = roleScores.get(trackId)!.get(role)!;
assignments.push({
role,
sourceTrack: trackId,
events: trackNotes,
confidence
chords,
confidence,
features: trackFeatures
});
}
console.log('Role assignments:', Array.from(assignedRoles.entries()));
return assignments;
}
private extractTrackFeatures(events: NoteEvent[]): TrackFeatures {
if (events.length === 0) {
return this.getDefaultTrackFeatures();
}
const pitches = events.map(e => e.pitch).sort((a, b) => a - b);
const medianPitch = pitches[Math.floor(pitches.length / 2)];
const pitchRange = Math.max(...pitches) - Math.min(...pitches);
const duration = Math.max(...events.map(e => e.time + e.duration)) - Math.min(...events.map(e => e.time));
const noteDensity = events.length / Math.max(duration, 1);
const polyphonyRatio = this.calculatePolyphony(events);
const averageDuration = events.reduce((sum, e) => sum + e.duration, 0) / events.length;
const repetitionScore = this.calculateRepetition(events);
const isMonophonic = polyphonyRatio < 0.1;
const hasPhraseContinuity = this.detectPhraseContinuity(events);
let register: 'low' | 'mid' | 'high';
if (medianPitch < 48) register = 'low';
else if (medianPitch < 72) register = 'mid';
else register = 'high';
return {
medianPitch,
pitchRange,
noteDensity,
polyphonyRatio,
averageDuration,
repetitionScore,
isMonophonic,
hasPhraseContinuity,
register
};
}
private calculateRoleScores(features: TrackFeatures, globalFeatures: StructuralFeatures): Map<Role, number> {
const scores = new Map<Role, number>();
// Bass scoring
let bassScore = 0;
if (features.register === 'low') bassScore += 0.6;
if (features.isMonophonic) bassScore += 0.2;
if (features.averageDuration < 1.0) bassScore += 0.2;
if (features.repetitionScore > 0.3) bassScore += 0.1;
scores.set('bass', Math.min(bassScore, 1.0));
// Melody scoring (highest priority for monophonic mid/high register with continuity)
let melodyScore = 0;
if (features.isMonophonic) melodyScore += 0.4;
if (features.hasPhraseContinuity) melodyScore += 0.3;
if (features.register === 'mid' || features.register === 'high') melodyScore += 0.2;
if (features.pitchRange > 12) melodyScore += 0.1; // Wide range suggests melody
scores.set('melody', Math.min(melodyScore, 1.0));
// Drone scoring
let droneScore = 0;
if (features.averageDuration > 2.0) droneScore += 0.5;
if (features.polyphonyRatio > 0.3) droneScore += 0.2; // Chords work for drone
if (features.noteDensity < 2.0) droneScore += 0.2; // Sparse notes
if (features.repetitionScore < 0.2) droneScore += 0.1; // Not too repetitive
scores.set('drone', Math.min(droneScore, 1.0));
// Ostinato scoring
let ostinatoScore = 0;
if (features.repetitionScore > 0.5) ostinatoScore += 0.4;
if (features.averageDuration < 0.8) ostinatoScore += 0.2;
if (features.noteDensity > 3.0) ostinatoScore += 0.2;
if (features.pitchRange < 12) ostinatoScore += 0.1; // Limited range
scores.set('ostinato', Math.min(ostinatoScore, 1.0));
// Texture scoring (catch-all for polyphonic accompaniment)
let textureScore = 0;
if (features.polyphonyRatio > 0.2) textureScore += 0.3;
if (features.register === 'mid') textureScore += 0.2;
if (features.noteDensity > 1.0 && features.noteDensity < 4.0) textureScore += 0.2;
if (features.averageDuration > 0.5 && features.averageDuration < 3.0) textureScore += 0.1;
if (features.repetitionScore < 0.4) textureScore += 0.1;
scores.set('texture', Math.min(textureScore, 1.0));
// Accents scoring (high velocity, sparse, punchy)
let accentsScore = 0;
if (features.noteDensity < 1.0) accentsScore += 0.3; // Sparse
if (features.averageDuration < 0.5) accentsScore += 0.3; // Short
if (features.register === 'high') accentsScore += 0.2;
scores.set('accents', Math.min(accentsScore, 1.0));
return scores;
}
private allocateRoles(roleScores: Map<number, Map<Role, number>>, trackEvents: Map<number, NoteEvent[]>): Map<number, Role> {
const assignments = new Map<number, Role>();
const assignedRoles = new Set<Role>();
// Sort tracks by their best role scores
const trackRolePrefs = Array.from(roleScores.entries()).map(([trackId, scores]) => {
const bestRole = Array.from(scores.entries()).reduce((a, b) => a[1] > b[1] ? a : b);
return { trackId, role: bestRole[0], score: bestRole[1] };
}).sort((a, b) => b.score - a.score);
// Assign roles greedily, but allow some duplication
for (const { trackId, role, score } of trackRolePrefs) {
if (score < 0.3) continue; // Skip low-confidence assignments
// Allow multiple texture/ostinato tracks, but prefer unique roles for others
if (!assignedRoles.has(role) || role === 'texture' || role === 'ostinato') {
assignments.set(trackId, role);
assignedRoles.add(role);
} else {
// Find next best role for this track
const scores = roleScores.get(trackId)!;
const alternatives = Array.from(scores.entries())
.filter(([r]) => !assignedRoles.has(r) || r === 'texture')
.sort((a, b) => b[1] - a[1]);
if (alternatives.length > 0 && alternatives[0][1] > 0.2) {
assignments.set(trackId, alternatives[0][0]);
assignedRoles.add(alternatives[0][0]);
} else {
// Fallback to texture
assignments.set(trackId, 'texture');
}
}
}
return assignments;
}
private extractChords(events: NoteEvent[]): ChordEvent[] {
const chords: ChordEvent[] = [];
const timeGrouping = 0.05; // 50ms window for simultaneous notes
// Group events by time
const timeGroups = new Map<number, NoteEvent[]>();
for (const event of events) {
const timeKey = Math.round(event.time / timeGrouping) * timeGrouping;
if (!timeGroups.has(timeKey)) {
timeGroups.set(timeKey, []);
}
timeGroups.get(timeKey)!.push(event);
}
// Convert groups with multiple notes to chords
for (const [time, groupEvents] of timeGroups) {
if (groupEvents.length > 1) {
// Sort by pitch and create chord
groupEvents.sort((a, b) => a.pitch - b.pitch);
const avgDuration = groupEvents.reduce((sum, e) => sum + e.duration, 0) / groupEvents.length;
const avgVelocity = groupEvents.reduce((sum, e) => sum + e.velocity, 0) / groupEvents.length;
chords.push({
time,
duration: avgDuration,
pitches: groupEvents.map(e => e.pitch),
velocity: avgVelocity,
track: groupEvents[0].track
});
}
}
return chords.sort((a, b) => a.time - b.time);
}
private calculatePolyphony(events: NoteEvent[]): number {
let simultaneousCount = 0;
let totalChecks = 0;
for (let i = 0; i < events.length; i++) {
const event = events[i];
let concurrent = 0;
for (let j = 0; j < events.length; j++) {
if (i === j) continue;
const other = events[j];
// Check if notes overlap in time
if (other.time < event.time + event.duration && other.time + other.duration > event.time) {
concurrent++;
}
}
simultaneousCount += concurrent;
totalChecks++;
}
return totalChecks > 0 ? simultaneousCount / totalChecks : 0;
}
private calculateRepetition(events: NoteEvent[]): number {
if (events.length < 4) return 0;
// Simple repetition detection: look for repeated pitch patterns
let repetitions = 0;
const windowSize = 4;
for (let i = 0; i <= events.length - windowSize * 2; i++) {
const pattern1 = events.slice(i, i + windowSize).map(e => e.pitch);
for (let j = i + windowSize; j <= events.length - windowSize; j++) {
const pattern2 = events.slice(j, j + windowSize).map(e => e.pitch);
if (this.arraysEqual(pattern1, pattern2)) {
repetitions++;
break;
}
}
}
return repetitions / Math.max(events.length - windowSize, 1);
}
private detectPhraseContinuity(events: NoteEvent[]): boolean {
if (events.length < 8) return false;
// Look for melodic motion (stepwise or small interval movement)
let stepwiseMotion = 0;
for (let i = 1; i < events.length; i++) {
const interval = Math.abs(events[i].pitch - events[i-1].pitch);
if (interval >= 1 && interval <= 4) { // Steps and small leaps
stepwiseMotion++;
}
}
return stepwiseMotion / (events.length - 1) > 0.4;
}
private arraysEqual(a: number[], b: number[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}
private groupEventsByTrack(events: NoteEvent[]): Map<number, NoteEvent[]> {
const tracks = new Map<number, NoteEvent[]>();
@@ -33,39 +281,17 @@ export class RoleMapper {
return tracks;
}
private determineRole(events: NoteEvent[], features: StructuralFeatures): Role {
if (events.length === 0) return 'texture';
const avgPitch = events.reduce((sum, e) => sum + e.pitch, 0) / events.length;
const avgDuration = events.reduce((sum, e) => sum + e.duration, 0) / events.length;
const avgVelocity = events.reduce((sum, e) => sum + e.velocity, 0) / events.length;
// Bass: low register, rhythmic
if (avgPitch < 48 && avgDuration < 1.0) {
return 'bass';
}
// Drone: sustained notes
if (avgDuration > 2.0) {
return 'drone';
}
// Ostinato: repetitive short notes
if (avgDuration < 0.5 && events.length > 10) {
return 'ostinato';
}
// Accents: high velocity peaks
if (avgVelocity > 0.8) {
return 'accents';
}
return 'texture';
}
private calculateConfidence(events: NoteEvent[], role: Role): number {
// Simple confidence calculation based on how well events fit the role
// In a real implementation, this would be more sophisticated
return 0.7 + Math.random() * 0.3;
private getDefaultTrackFeatures(): TrackFeatures {
return {
medianPitch: 60,
pitchRange: 12,
noteDensity: 2.0,
polyphonyRatio: 0,
averageDuration: 0.5,
repetitionScore: 0.2,
isMonophonic: true,
hasPhraseContinuity: false,
register: 'mid'
};
}
}
+4 -2
View File
@@ -16,7 +16,8 @@ export class MIDIProcessor {
totalDuration,
noteDensity,
registerDistribution,
trackRoles: new Map()
trackRoles: new Map(),
trackFeatures: new Map()
};
}
@@ -76,7 +77,8 @@ export class MIDIProcessor {
totalDuration: 30,
noteDensity: [4, 4, 4, 4, 4, 4, 4, 4],
registerDistribution: { low: 0.3, mid: 0.5, high: 0.2 },
trackRoles: new Map()
trackRoles: new Map(),
trackFeatures: new Map()
};
}
}
+212 -88
View File
@@ -1,14 +1,16 @@
import type { RoleAssignment, MotifConfig, SynthLayer, Role } from '../types';
import type { RoleAssignment, MotifConfig, SynthLayer, Role, NoteEvent, ChordEvent } from '../types';
export class SynthesisEngine {
private audioContext: AudioContext;
private config: MotifConfig;
private masterGain: GainNode;
private layers: Map<Role, SynthLayer> = new Map();
private roleAssignments: Map<Role, RoleAssignment> = new Map();
private isPlaying = false;
private schedulerIntervalId: number | null = null;
private currentTime = 0;
private startTime = 0;
private nextEventIndex = new Map<Role, number>();
constructor(audioContext: AudioContext, config: MotifConfig) {
this.audioContext = audioContext;
@@ -22,10 +24,15 @@ export class SynthesisEngine {
// Clean up existing layers
this.cleanupLayers();
// Store role assignments and create layers
for (const assignment of assignments) {
const layer = this.createSynthLayer(assignment.role);
this.layers.set(assignment.role, layer);
this.roleAssignments.set(assignment.role, assignment);
this.nextEventIndex.set(assignment.role, 0);
}
console.log('Setup layers for roles:', Array.from(this.roleAssignments.keys()));
}
start(): void {
@@ -35,13 +42,17 @@ export class SynthesisEngine {
this.startTime = this.audioContext.currentTime;
this.currentTime = 0;
// Start continuous layers (drone, texture)
this.startContinuousLayers();
// Reset event indices
for (const role of this.roleAssignments.keys()) {
this.nextEventIndex.set(role, 0);
}
// Start scheduler for rhythmic layers
// Start scheduler to play actual MIDI events
this.schedulerIntervalId = window.setInterval(() => {
this.scheduleEvents();
}, this.config.scheduleInterval);
console.log('Started synthesis with', this.roleAssignments.size, 'roles');
}
stop(): void {
@@ -106,65 +117,63 @@ export class SynthesisEngine {
}
}
private startContinuousLayers(): void {
const droneLayer = this.layers.get('drone');
if (droneLayer) {
this.startDrone(droneLayer);
}
const textureLayer = this.layers.get('texture');
if (textureLayer) {
this.startTexture(textureLayer);
}
private midiToFrequency(midiNote: number): number {
return 440 * Math.pow(2, (midiNote - 69) / 12);
}
private startDrone(layer: SynthLayer): void {
const osc1 = this.audioContext.createOscillator();
const osc2 = this.audioContext.createOscillator();
private scheduleNote(role: Role, pitch: number, duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
osc1.frequency.value = 110; // A2
osc2.frequency.value = 110.5; // Slight detune
osc1.type = 'sawtooth';
osc2.type = 'sawtooth';
osc1.connect(layer.filterNode);
osc2.connect(layer.filterNode);
osc1.start();
osc2.start();
layer.oscillators.push(osc1, osc2);
}
private startTexture(layer: SynthLayer): void {
// Create evolving texture with multiple oscillators
for (let i = 0; i < 4; i++) {
setTimeout(() => {
if (!this.isPlaying) return;
this.addTextureOscillator(layer);
}, i * 2000);
}
}
private addTextureOscillator(layer: SynthLayer): void {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
osc.frequency.value = 220 + Math.random() * 880; // Random frequency
osc.type = 'triangle';
// Convert MIDI pitch to frequency
const frequency = this.midiToFrequency(pitch);
osc.frequency.value = frequency;
// Choose oscillator type based on role
switch (role) {
case 'bass':
osc.type = 'square';
break;
case 'drone':
osc.type = 'sawtooth';
break;
case 'ostinato':
osc.type = 'triangle';
break;
case 'texture':
case 'accents':
osc.type = 'sine';
break;
}
osc.connect(envelope);
envelope.connect(layer.filterNode);
// Slow attack and decay
envelope.gain.setValueAtTime(0, this.audioContext.currentTime);
envelope.gain.linearRampToValueAtTime(0.1, this.audioContext.currentTime + 4);
envelope.gain.linearRampToValueAtTime(0, this.audioContext.currentTime + 8);
// Envelope based on velocity and duration
const gainValue = velocity * 0.5; // Scale velocity
const attackTime = Math.min(0.05, duration * 0.1);
const releaseTime = Math.min(0.1, duration * 0.3);
osc.start();
osc.stop(this.audioContext.currentTime + 8);
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + duration - releaseTime);
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration);
layer.oscillators.push(osc);
osc.start(when);
osc.stop(when + duration);
// Clean up after note ends
setTimeout(() => {
try {
osc.disconnect();
envelope.disconnect();
} catch (e) {
// Already disconnected
}
}, (duration + 0.1) * 1000);
}
private scheduleEvents(): void {
@@ -173,54 +182,106 @@ export class SynthesisEngine {
const currentTime = this.audioContext.currentTime;
const scheduleUntil = currentTime + this.config.lookaheadTime;
// Schedule bass hits
this.scheduleBassHits(scheduleUntil);
// Schedule ostinato patterns
this.scheduleOstinato(scheduleUntil);
this.currentTime = (currentTime - this.startTime) % 32; // Loop every 32 seconds
}
private scheduleBassHits(scheduleUntil: number): void {
const bassLayer = this.layers.get('bass');
if (!bassLayer) return;
const beatInterval = 60 / 120; // 120 BPM
const nextBeat = Math.ceil(this.currentTime / beatInterval) * beatInterval;
const scheduleTime = this.startTime + nextBeat;
if (scheduleTime <= scheduleUntil && nextBeat % 1 < 0.1) { // On downbeat
this.triggerBassHit(bassLayer, scheduleTime);
// Schedule events for each role
for (const [role, assignment] of this.roleAssignments) {
this.scheduleRoleEvents(role, assignment, scheduleUntil);
}
}
private triggerBassHit(layer: SynthLayer, when: number): void {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
private scheduleRoleEvents(role: Role, assignment: RoleAssignment, scheduleUntil: number): void {
const events = assignment.events;
const chords = assignment.chords;
osc.frequency.value = 55; // A1
osc.type = 'square';
if (!events.length && !chords.length) return;
osc.connect(envelope);
envelope.connect(layer.filterNode);
let eventIndex = this.nextEventIndex.get(role) || 0;
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(0.8, when + 0.01);
envelope.gain.exponentialRampToValueAtTime(0.001, when + 0.5);
osc.start(when);
osc.stop(when + 0.5);
// For roles that support polyphony (drone, texture), prefer chords
if ((role === 'drone' || role === 'texture') && chords.length > 0) {
this.scheduleChordEvents(role, chords, scheduleUntil);
} else {
this.scheduleSingleEvents(role, events, scheduleUntil);
}
}
private scheduleOstinato(scheduleUntil: number): void {
// Simple ostinato pattern - implementation would be more sophisticated
const ostinatoLayer = this.layers.get('ostinato');
if (!ostinatoLayer) return;
private scheduleChordEvents(role: Role, chords: ChordEvent[], scheduleUntil: number): void {
let chordIndex = this.nextEventIndex.get(role) || 0;
// Implementation for rhythmic patterns would go here
while (chordIndex < chords.length) {
const chord = chords[chordIndex];
const eventTime = this.startTime + chord.time;
// Stop if we're past the lookahead window
if (eventTime > scheduleUntil) break;
// Schedule if the chord hasn't been played yet
if (eventTime >= this.audioContext.currentTime) {
this.scheduleChord(
role,
chord.pitches,
Math.max(0.05, chord.duration),
chord.velocity,
eventTime
);
}
chordIndex++;
}
// Update the next event index
this.nextEventIndex.set(role, chordIndex);
// Loop if we've reached the end
if (chordIndex >= chords.length) {
this.nextEventIndex.set(role, 0);
// Reset start time for looping
if (Array.from(this.nextEventIndex.values()).every(idx => idx === 0)) {
this.startTime = this.audioContext.currentTime;
}
}
}
private scheduleSingleEvents(role: Role, events: NoteEvent[], scheduleUntil: number): void {
let eventIndex = this.nextEventIndex.get(role) || 0;
while (eventIndex < events.length) {
const event = events[eventIndex];
const eventTime = this.startTime + event.time;
// Stop if we're past the lookahead window
if (eventTime > scheduleUntil) break;
// Schedule if the event hasn't been played yet
if (eventTime >= this.audioContext.currentTime) {
this.scheduleNote(
role,
event.pitch,
Math.max(0.05, event.duration), // Minimum duration
event.velocity,
eventTime
);
}
eventIndex++;
}
// Update the next event index
this.nextEventIndex.set(role, eventIndex);
// Loop if we've reached the end
if (eventIndex >= events.length) {
this.nextEventIndex.set(role, 0);
// Reset start time for looping
if (Array.from(this.nextEventIndex.values()).every(idx => idx === 0)) {
this.startTime = this.audioContext.currentTime;
}
}
}
private fadeOutAllLayers(): void {
const fadeTime = this.config.fadeTime;
const when = this.audioContext.currentTime;
@@ -234,6 +295,69 @@ export class SynthesisEngine {
}, fadeTime * 1000 + 100);
}
private scheduleChord(role: Role, pitches: number[], duration: number, velocity: number, when: number): void {
const layer = this.layers.get(role);
if (!layer) return;
// Create oscillator for each pitch in the chord
for (const pitch of pitches) {
const osc = this.audioContext.createOscillator();
const envelope = this.audioContext.createGain();
// Convert MIDI pitch to frequency
const frequency = this.midiToFrequency(pitch);
osc.frequency.value = frequency;
// Choose oscillator type based on role
switch (role) {
case 'bass':
osc.type = 'square';
break;
case 'drone':
osc.type = 'sawtooth';
break;
case 'ostinato':
osc.type = 'triangle';
break;
case 'texture':
osc.type = 'sine';
break;
case 'melody':
osc.type = 'triangle';
break;
case 'accents':
osc.type = 'sine';
break;
}
osc.connect(envelope);
envelope.connect(layer.filterNode);
// Envelope based on velocity and duration, scaled for chords
const gainValue = (velocity * 0.3) / Math.max(pitches.length * 0.5, 1); // Scale down for chords
const attackTime = Math.min(0.05, duration * 0.1);
const releaseTime = Math.min(0.1, duration * 0.3);
envelope.gain.setValueAtTime(0, when);
envelope.gain.linearRampToValueAtTime(gainValue, when + attackTime);
envelope.gain.linearRampToValueAtTime(gainValue * 0.7, when + duration - releaseTime);
envelope.gain.exponentialRampToValueAtTime(0.001, when + duration);
osc.start(when);
osc.stop(when + duration);
// Clean up after note ends
setTimeout(() => {
try {
osc.disconnect();
envelope.disconnect();
} catch (e) {
// Already disconnected
}
}, (duration + 0.1) * 1000);
}
}
private cleanupLayers(): void {
for (const layer of this.layers.values()) {
for (const osc of layer.oscillators) {
+24 -1
View File
@@ -6,21 +6,44 @@ export interface NoteEvent {
track: number;
}
export interface ChordEvent {
time: number;
duration: number;
pitches: number[];
velocity: number;
track: number;
}
export interface TrackFeatures {
medianPitch: number;
pitchRange: number;
noteDensity: number;
polyphonyRatio: number;
averageDuration: number;
repetitionScore: number;
isMonophonic: boolean;
hasPhraseContinuity: boolean;
register: 'low' | 'mid' | 'high';
}
export interface StructuralFeatures {
tempo: number;
totalDuration: number;
noteDensity: number[];
registerDistribution: { low: number; mid: number; high: number };
trackRoles: Map<number, Role>;
trackFeatures: Map<number, TrackFeatures>;
}
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents';
export type Role = 'bass' | 'drone' | 'ostinato' | 'texture' | 'accents' | 'melody';
export interface RoleAssignment {
role: Role;
sourceTrack: number;
events: NoteEvent[];
chords: ChordEvent[];
confidence: number;
features: TrackFeatures;
}
export interface SynthLayer {