import { SetAssetPropertyCommand } from '../../core/commands/assetCommands'; import { instrumentBuildFor, instrumentSourcesOf, namedNotes, playedNotes, } from '../../core/instrument/compile'; import { ghostNotes, placeOf } from '../../core/music/ghosts'; import { GRID_OPTIONS, PATTERN_GRID_KEY, patternInstrument, patternLoop, patternMeter, patternSequence, patternTempo, } from '../../core/music/pattern'; import type { Asset, PatternAsset } from '../../core/model/Asset'; import { textOf } from '../../core/model/Properties'; import { findAssetOf } from '../../core/model/Project'; import { fxVoiceOf } from '../../core/music/needs'; import { assetFxOf } from '../../core/properties/assetFx'; import { DEFAULT_TEMPO } from '../../core/music/time'; import { instrumentFx, playNote } from '../../runtime/systems/behaviorContext'; import { Button, Dropdown, Label, Menu, type Widget } from '../../ui'; import { Music } from '../../viewport/music'; import { playedBus } from '../../core/music/mixer'; import { soundLibrary } from '../../viewport/SoundLibrary'; import { AssetTabEditor } from '../AssetTabEditor'; import { claimTransport, releaseTransport } from '../music/transport'; import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons'; import type { EditorState } from '../state'; import { PianoRoll } from './PianoRoll'; const INFO_WIDTH = 200; const GRID_WIDTH = 64; /** How long a note drawn or moved sounds. */ const PREVIEW_SECONDS = 0.3; /** Whose voices the tab starts for a key or a drawn note, apart from those the scheduler plays. */ const PREVIEW_OWNER = 'pattern-preview'; /** * The Pattern tab (#333): the piano roll, and in the header Play – the Space bar does the same – * and the grid notes are drawn on. The pattern plays round its loop through the scheduler (#332); * an edit is heard from its next window on, without starting over. */ export class PatternEditor extends AssetTabEditor { /** Public for the check scripts. */ readonly playButton: Button; readonly gridButton = new Dropdown('1/16', { width: GRID_WIDTH }); /** Whether a note drawn, grabbed or moved sounds; off by default. A key down the left always does. */ readonly auditionButton: Button; /** Whether the notes the song plays around the pattern are shown (#339); on by default. */ readonly ghostsButton: Button; /** Where the ghost notes come from: song, track and bar. */ readonly ghostSource = new Label('', { style: 'muted' }); /** Heard through the sound FX (#352) and on the bus of the asset and the master, as in the game (#325). */ readonly music = new Music( () => this.state.project, () => playedBus(this.state.project, '', this.asset()?.props ?? {}), () => { const asset = this.asset(); return asset && fxVoiceOf(this.state.project, assetFxOf(asset.props), patternTempo(asset.props)); }, ); /** The pattern playing, `''` while none is. */ private playingId = ''; /** What the scheduler was last told, so only a change is passed on. */ private told = ''; private readonly halt = () => this.stop(); constructor(state: EditorState) { super(state, { infoWidth: INFO_WIDTH }); this.playButton = new Button('Play', { icon: TOOLBAR_ICON_SVG.play, minWidth: 72, tooltip: 'Play the pattern round its loop (Space)', onClick: () => this.togglePlay(), }); this.gridButton.onClick = () => this.openGridMenu(); this.auditionButton = new Button('Audition', { toggle: true, tooltip: 'Let a note sound when it is drawn, grabbed or moved', onClick: () => this.auditionButton.setToggled(!this.auditionButton.toggled), }); this.ghostsButton = new Button('Ghosts', { toggle: true, tooltip: () => this.ghostsButton.enabled ? 'Show what the song plays around this pattern, faint' : 'Double-click an item of this pattern in a song to see what plays around it', onClick: () => { this.ghostsButton.setToggled(!this.ghostsButton.toggled); this.refresh(); }, }); this.ghostsButton.setToggled(true); const roll = new PianoRoll(state); roll.onPreview = (note, held) => this.preview(note, held); roll.onRelease = (voice) => { soundLibrary.release(voice); this.music.idle(); }; roll.onTogglePlay = () => this.togglePlay(); roll.onStart = (tick) => { if (this.isPlaying) this.music.current?.seek(tick); }; this.mount(roll, [ this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton, this.auditionButton, this.ghostsButton, this.ghostSource, ]); state.on('load', () => this.stop()); } override selectedId(): string { return this.state.selectedPatternId ?? ''; } override isMine(asset: Asset): asset is PatternAsset { return asset.type === 'pattern'; } get isPlaying(): boolean { return this.playingId !== ''; } togglePlay(): void { if (this.isPlaying) this.stop(); else this.play(); } /** Plays the pattern shown from where the ruler says, round its loop. */ play(): void { const asset = this.asset(); if (!asset) return; claimTransport(this.halt); this.playingId = asset.id; this.told = this.settingsOf(asset); void this.music.play(patternSequence(asset), { tempo: patternTempo(asset.props), meter: patternMeter(asset.props), loop: patternLoop(asset), from: this.canvas.start, }); this.syncPlayButton(); } stop(): void { releaseTransport(this.halt); this.music.stop(); this.playingId = ''; this.canvas.playhead = null; this.canvas.invalidate(); this.syncPlayButton(); } /** Only called while the tab is visible: the playhead follows what is heard. */ tick(): void { if (!this.isPlaying) return; this.canvas.playhead = this.music.current?.position().ticks ?? null; this.canvas.invalidate(); } override destroy(options?: Parameters['destroy']>[0]): void { this.music.stop(); super.destroy(options); } /** A key of the roll is held until it is let go; a note drawn or moved sounds for a moment with Audition on. */ private preview(note: number, held: boolean): number { if (!held && !this.auditionButton.toggled) return 0; const asset = this.asset(); const instrument = asset ? patternInstrument(asset.props) : ''; if (!instrument) return 0; const struck = { note, velocity: 1, volume: 1, duration: held ? 0 : PREVIEW_SECONDS }; if (!held) setTimeout(() => this.music.idle(), PREVIEW_SECONDS * 1000); return playNote( { project: this.state.project, audio: soundLibrary }, instrument, struck, PREVIEW_OWNER, {}, { bus: this.music.heardOn() }, fxVoiceOf(this.state.project, instrumentFx(this.state.project, instrument, ''), DEFAULT_TEMPO), ); } private openGridMenu(): void { const asset = this.asset(); if (!asset) return; const current = textOf(asset.props[PATTERN_GRID_KEY] ?? ''); new Menu( GRID_OPTIONS.map((option) => ({ label: option.label, current: option.value === current, onSelect: () => this.state.execute(new SetAssetPropertyCommand(asset.id, PATTERN_GRID_KEY, option.value)), })), ).openBelow(this.gridButton); } private settingsOf(asset: PatternAsset): string { return JSON.stringify([patternTempo(asset.props), patternMeter(asset.props), patternLoop(asset)]); } /** What changed while playing goes to the scheduler; another pattern shown stops it. */ private follow(asset: PatternAsset | null): void { if (!this.isPlaying) return; if (!asset || asset.id !== this.playingId) { this.stop(); return; } this.music.edit(patternSequence(asset)); const settings = this.settingsOf(asset); if (settings === this.told) return; this.told = settings; const scheduler = this.music.current; scheduler?.setTempo(patternTempo(asset.props)); scheduler?.setMeter(patternMeter(asset.props)); scheduler?.setLoop(patternLoop(asset)); } private syncPlayButton(): void { this.playButton.setIcon(this.isPlaying ? TOOLBAR_ICON_SVG.stop : TOOLBAR_ICON_SVG.play); this.playButton.setText(this.isPlaying ? 'Stop' : 'Play'); } protected override infoText(asset: PatternAsset): string { const count = asset.notes.length; const notes = `${count} ${count === 1 ? 'note' : 'notes'}`; const instrument = findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument'); return instrument ? `${notes} · ${instrument.name}` : `${notes} · no instrument`; } protected override syncExtras(asset: PatternAsset | null): void { this.fitButton.setEnabled(asset !== null); this.playButton.setEnabled(asset !== null); this.gridButton.setEnabled(asset !== null); this.gridButton.setText(asset ? textOf(asset.props[PATTERN_GRID_KEY] ?? '1/16') : '1/16'); const instrument = asset ? findAssetOf(this.state.project, patternInstrument(asset.props), 'instrument') : undefined; if (instrument) { const sources = instrumentSourcesOf(this.state.project); const sounding = [...playedNotes(instrument.graph)].filter( (note) => instrumentBuildFor(instrument, note, sources).layers.length > 0, ); this.canvas.setInstrument(new Set(sounding), namedNotes(instrument.graph)); } else this.canvas.setInstrument(new Set(), new Map()); this.syncGhosts(asset); this.follow(asset); } /** The song item the pattern was opened from – or the only one it lies in – and what plays around it. */ private syncGhosts(asset: PatternAsset | null): void { const project = this.state.project; const place = asset && placeOf(project, asset.id, this.state.patternPlaces.get(asset.id)); const ghosts = asset && place ? ghostNotes(project, asset.id, place) : null; this.ghostsButton.setEnabled(ghosts !== null); this.canvas.setGhosts(this.ghostsButton.toggled ? ghosts : null); const source = ghosts ? `${ghosts.song} · ${ghosts.track} · bar ${ghosts.bar}` : ''; if (source === this.ghostSource.text) return; this.ghostSource.setText(source); (this.ghostSource.parent as Widget | null)?.layout(); } }