Skip to content

Commit

Permalink
Render gap overlays for nonmusical fragments
Browse files Browse the repository at this point in the history
  • Loading branch information
jaredjj3 committed Jan 15, 2025
1 parent 00aacf8 commit bc48866
Show file tree
Hide file tree
Showing 12 changed files with 224 additions and 65 deletions.
3 changes: 3 additions & 0 deletions site/src/components/Vexml.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,11 @@ export const Vexml = ({ musicXML, config, onResult, onClick, onLongpress, onEnte
const defaultFormatter = new vexml.DefaultFormatter({ config: vexmlConfig });
const monitoredFormatter = new vexml.MonitoredFormatter(defaultFormatter, logger, { config: vexmlConfig });
const renderer = new vexml.Renderer({ config: vexmlConfig, formatter: monitoredFormatter, logger });

const document = parser.parse(musicXML);

const formattedDocument = monitoredFormatter.format(document);

score = renderer.render(div, formattedDocument);
setScore(score);

Expand Down
62 changes: 58 additions & 4 deletions src/data/document.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import * as util from '@/util';
import { Score } from './types';
import * as errors from '@/errors';
import { NonMusicalFragment as NonMusicalFragment, Score } from './types';

const DEFAULT_FRAGMENT_WIDTH = 300;

/** Document is an interface for mutating a {@link Score}. */
export class Document {
Expand All @@ -20,9 +23,60 @@ export class Document {
});
}

/** Inserts a gap at specified measure and fragment indexes. */
insertGap(): void {
throw new Error('Method not implemented.');
/**
* Mutates the score by inserting a gap measure with the given message. It will cause the measure indexes to shift,
* but not the measure labels.
*/
insertGapMeasureBefore(opts: {
absoluteMeasureIndex: number;
durationMs: number;
minWidth?: number;
label?: string;
}): void {
// Inserting gaps requires us to know about the part and stave signatures, so we can visually extend the measure
// that precedes it.

const measures = this.score.systems.flatMap((system) => system.measures);
if (measures.length === 0) {
throw new errors.DocumentError('cannot insert gap into empty score');
}

if (opts.absoluteMeasureIndex > measures.length) {
throw new errors.DocumentError('cannot insert gap after non-existent measure');
}

// First, find a template that we'll copy to create the gap.
const templateMeasure = measures[opts.absoluteMeasureIndex];

// Clone the template. We'll mutate the clone and insert it into the score.
const cloneMeasure = util.deepClone(templateMeasure);

if (cloneMeasure.fragments.length === 0) {
throw new errors.DocumentError('cannot insert gap into empty measure');
}

// Update the measure properties we don't care about.
cloneMeasure.label = null;
cloneMeasure.fragments.splice(0, cloneMeasure.fragments.length - 1);

// Transform the fragment into a non-musical gap.
cloneMeasure.fragments[0].kind = 'nonmusical';

const gapFragment = cloneMeasure.fragments[0] as NonMusicalFragment;
gapFragment.durationMs = opts.durationMs;
gapFragment.label = opts.label ?? null;
gapFragment.minWidth = opts.minWidth ?? DEFAULT_FRAGMENT_WIDTH;

// Get rid of all the voices in the parts, since we're potentially just rendering a label.
gapFragment.parts
.flatMap((part) => part.staves)
.forEach((stave) => {
stave.voices = [];
});

// Insert the gap into the score into the same system as the template.
const systemIndex = this.score.systems.findIndex((system) => system.measures.includes(templateMeasure));
this.score.systems[systemIndex].measures.splice(opts.absoluteMeasureIndex, 0, cloneMeasure);
}

clone(): Document {
Expand Down
25 changes: 15 additions & 10 deletions src/data/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,26 +100,31 @@ export type Jump =
| { type: 'repeatend'; times: number }
| { type: 'repeatending'; times: number; label: string; endingBracketType: EndingBracketType };

export type Fragment = {
export type Fragment = MusicalFragment | NonMusicalFragment;

export type MusicalFragment = {
type: 'fragment';
kind: 'musical';
signature: FragmentSignature;
parts: Part[];
width: number | null;
minWidth: number | null;
};

export type NonMusicalFragment = {
type: 'fragment';
kind: 'nonmusical';
signature: FragmentSignature;
parts: Part[];
minWidth: number | null;
label: string | null;
durationMs: number;
};

export type FragmentSignature = {
type: 'fragmentsignature';
metronome: Metronome;
};

export type Gap = {
type: 'gap';
text: string | null;
width: number | null;
parts: Part[];
durationMs: number;
};

export type Part = {
type: 'part';
staves: Stave[];
Expand Down
7 changes: 7 additions & 0 deletions src/elements/score.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,13 @@ export class Score {
v.setContext(ctx).draw();
});

// Draw gap overlays.
fragmentRenders
.map((f) => f.gapOverlay)
.forEach((g) => {
g?.setContext(ctx).draw();
});

// Draw the debug system rects.
if (config.DEBUG_DRAW_SYSTEM_RECTS) {
systemRenders.forEach((s) => {
Expand Down
48 changes: 9 additions & 39 deletions src/errors/errors.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
export type VexmlErrorCode =
| 'GENERIC_ERROR'
| 'PARSE_ERROR'
| 'PRE_RENDER_ERROR'
| 'RENDER_ERROR'
| 'POST_RENDER_ERROR'
| 'PLAYBACK_ERROR';
export type VexmlErrorCode = 'GENERIC_ERROR' | 'DOCUMENT_ERROR' | 'PARSE_ERROR';

/** A generic vexml error. */
export class VexmlError extends Error {
Expand All @@ -17,42 +11,18 @@ export class VexmlError extends Error {
}
}

/** An error thrown during the parsing process. */
export class ParseError extends VexmlError {
constructor(message: string) {
super(message, 'PARSE_ERROR');
this.name = 'ParseError';
}
}

/** An error thrown during the pre-rendering process. */
export class PreRenderError extends VexmlError {
constructor(message: string) {
super(message, 'PRE_RENDER_ERROR');
this.name = 'PreRenderError';
}
}

/** An error thrown during the rendering process. */
export class RenderError extends VexmlError {
constructor(message: string) {
super(message, 'RENDER_ERROR');
this.name = 'RenderError';
}
}

/** An error thrown during the post-rendering process. */
export class PostRenderError extends VexmlError {
/** An error thrown when attempting to mutate the document. */
export class DocumentError extends VexmlError {
constructor(message: string) {
super(message, 'POST_RENDER_ERROR');
this.name = 'PostRenderError';
super(message, 'DOCUMENT_ERROR');
this.name = 'DocumentError';
}
}

/** An error thrown during playback. */
export class PlaybackError extends VexmlError {
/** An error thrown during the parsing process. */
export class ParseError extends VexmlError {
constructor(message: string) {
super(message, 'PLAYBACK_ERROR');
this.name = 'PlaybackError';
super(message, 'PARSE_ERROR');
this.name = 'ParseError';
}
}
3 changes: 2 additions & 1 deletion src/parsing/musicxml/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ export class Fragment {

return {
type: 'fragment',
width: null,
kind: 'musical',
minWidth: null,
signature: this.signature.asFragmentSignature().parse(),
parts: this.parts.map((part) => part.parse(fragmentCtx)),
};
Expand Down
15 changes: 14 additions & 1 deletion src/rendering/fragment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { Pen } from './pen';
import { PartLabelGroup } from './partlabelgroup';
import { Budget } from './budget';
import { Ensemble } from './ensemble';
import { GapOverlay } from './gapoverlay';

const BARLINE_PADDING_RIGHT = 6;
const MEASURE_NUMBER_PADDING_LEFT = 6;
Expand All @@ -29,7 +30,12 @@ export class Fragment {

let widthBudget: Budget;
if (this.width === null) {
widthBudget = Budget.unlimited();
const minWidth = this.document.getFragment(this.key).minWidth;
if (minWidth) {
widthBudget = new Budget(minWidth);
} else {
widthBudget = Budget.unlimited();
}
} else {
widthBudget = new Budget(this.width);
}
Expand Down Expand Up @@ -58,6 +64,7 @@ export class Fragment {
partLabelGroupRender: null, // placeholder
vexflowStaveConnectors: [], // placeholder
partRenders,
gapOverlay: null, // placeholder
};
const throwawayFragmentRender: FragmentRender = {
type: 'fragment',
Expand All @@ -68,6 +75,7 @@ export class Fragment {
partLabelGroupRender: null, // placeholder
vexflowStaveConnectors: [], // placeholder
partRenders: throwawayPartRenders,
gapOverlay: null, // placeholder
};

let ensembleWidth: number | null;
Expand Down Expand Up @@ -117,6 +125,11 @@ export class Fragment {
fragmentRender.rect = Rect.merge([fragmentRender.rect, partLabelGroupRender.rect]);
}

const fragment = this.document.getFragment(this.key);
if (fragment.kind === 'nonmusical') {
fragmentRender.gapOverlay = new GapOverlay(this.config, this.log, fragment.label, fragmentRender);
}

return fragmentRender;
}

Expand Down
105 changes: 105 additions & 0 deletions src/rendering/gapoverlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as vexflow from 'vexflow';
import * as util from '@/util';
import { Config } from '@/config';
import { Logger } from '@/debug';
import { Point, Rect } from '@/spatial';
import { FragmentRender } from './types';
import { TextMeasurer } from './textmeasurer';
import { Label } from './label';

// TODO: Replace this with config, and/or maybe the data should live in data/types.
const DEFAULT_FONT_SIZE = '16px';
const DEFAULT_FONT_FAMILY = 'monospace';
const DEFAULT_STYLE_FONT_COLOR = 'rgb(230, 0, 0)';
const DEFAULT_STYLE_FILL = 'rgba(255, 0, 0, 0.2)';
const DEFAULT_STYLE_STROKE = 'rgba(255, 0, 0, 0.5)';

export type GapOverlayStyle = {
stroke?: string;
fill?: string;
};

export class GapOverlay {
private ctx: vexflow.RenderContext | null = null;

constructor(
private config: Config,
private log: Logger,
private label: string | null,
private fragmentRender: FragmentRender,
private style?: GapOverlayStyle
) {
util.assert(fragmentRender.rectSrc !== 'none'); // This means we can trust the rects.
}

setContext(ctx: vexflow.RenderContext): this {
this.ctx = ctx;
return this;
}

draw(): this {
const ctx = this.ctx;
util.assertNotNull(ctx);

const topRect = this.fragmentRender.partRenders.at(0)?.staveRenders.at(0)?.intrinsicRect;
util.assertDefined(topRect);

const bottomRect = this.fragmentRender.partRenders.at(-1)?.staveRenders.at(-1)?.intrinsicRect;
util.assertDefined(bottomRect);

const rect = Rect.merge([topRect, bottomRect]);

ctx.save();

this.drawRect(rect);

// Draw the label in the center of the overlay.
if (this.label) {
const textMeasurer = new TextMeasurer({ size: DEFAULT_FONT_SIZE, family: DEFAULT_FONT_FAMILY });
const measurement = textMeasurer.measure(this.label);

const x = rect.center().x - measurement.width / 2;
const y = rect.center().y + measurement.approximateHeight / 2;
const position = new Point(x, y);

Label.singleLine(
this.config,
this.log,
this.label,
position,
{},
{
size: DEFAULT_FONT_SIZE,
family: DEFAULT_FONT_FAMILY,
color: DEFAULT_STYLE_FONT_COLOR,
}
)
.setContext(ctx)
.draw();
}

ctx.restore();

return this;
}

private drawRect(rect: Rect): void {
const ctx = this.ctx;
util.assertNotNull(ctx);

ctx.save();

const stroke = this.style?.stroke ?? DEFAULT_STYLE_STROKE;
ctx.setStrokeStyle(stroke);
ctx.beginPath();
ctx.rect(rect.x, rect.y, rect.w, rect.h);
ctx.stroke();
ctx.closePath();

const fill = this.style?.fill ?? DEFAULT_STYLE_FILL;
ctx.setFillStyle(fill);
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);

ctx.restore();
}
}
9 changes: 7 additions & 2 deletions src/rendering/measure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,13 @@ export class Measure {

for (let fragmentIndex = 0; fragmentIndex < fragmentCount; fragmentIndex++) {
const key: FragmentKey = { ...this.key, fragmentIndex };
const fragmentRender = new Fragment(this.config, this.log, this.document, key, Point.origin(), null).render();
widths.push(fragmentRender.rect.w);
const fragment = this.document.getFragment(key);
if (typeof fragment.minWidth === 'number' && fragment.minWidth > 0) {
widths.push(fragment.minWidth);
} else {
const fragmentRender = new Fragment(this.config, this.log, this.document, key, Point.origin(), null).render();
widths.push(fragmentRender.rect.w);
}
}

const total = util.sum(widths);
Expand Down
2 changes: 1 addition & 1 deletion src/rendering/stave.ts
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ export class Stave {
currentMetronome.displayBpm || currentMetronome.dots || currentMetronome.dots2 || currentMetronome.duration;

if (hasMetronome && (isAbsolutelyFirst || didMetronomeChange)) {
vexflowStave.setTempo(currentMetronome, -METRONOME_TOP_PADDING);
vexflowStave.setTempo({ ...currentMetronome, bpm: currentMetronome.displayBpm }, -METRONOME_TOP_PADDING);
}
}

Expand Down
Loading

0 comments on commit bc48866

Please sign in to comment.