Lay patterns and sounds out on tracks and play them as a song #334
Patterns could only be heard one at a time, in their own tab or from a behavior. A song lays patterns and sounds out on tracks along one time axis and plays them through the scheduler of #332, the third piece of the music tools of #330. - New asset type song under the heading Songs, with four empty tracks, 120 BPM in 4/4 and a grid of a bar. Tempo, meter and grid are rows in the inspector. Tracks, items and the loop are the asset's data, held to their rules on load and in every command. - A track has a name, volume, pan, mute and solo. Each is heard on a bus of its own, so a change reaches what is sounding at once. - A pattern item repeats its pattern for as long as it is, with a transpose and another instrument if wanted. A sound item plays its sound as a clip at its own speed, round and round, through a sound FX if wanted. Both have a volume on top of their track's. - Cut back at the left end or split, an item starts as far into what it plays as the cut is: ticks into a pattern, seconds into a sound. - The tab shows the heads with M, S, volume and pan, the ruler of the Pattern tab and the items, a pattern with its notes, a sound with its waveform, a missing asset red. Patterns and sounds are dragged in from the Assets panel. Body, right and left end move, lengthen and cut back, Alt leaves the grid. Ctrl+C/V/D/A, Ctrl+E splits where playing starts, Delete, arrows. A double-click opens a pattern item in its tab. - What is picked is a new selection of its own, so the inspector shows the item or the track, with a button to delete it. - The scheduler plans clips beside notes. Where the playhead starts or jumps back inside a clip, it is taken up that far into its sound. A clip fades out at the end of its item or of the loop. - The editor plays one piece at a time: a pattern started stops the song and the other way round. - The rules are pure functions in core/music/song, editor/song/ songView and songEdit. Findings: - The suites of4ea752fand2a3b90bwere red at e2e 54: the Pattern pushed Struct below the fold of the + menu. The module now scrolls to it, as e2e 47 does for Vector art. It also left the menu open, which made e2e 55 fail behind it. - The Assets panel only dragged assets a scene can place. Patterns and sounds are dragged now as well, and a scene says why it does not take one instead of calling every asset a scene. Tests: - song.test.ts (new, 14) and song-edit.test.ts (new, 9): model, round trip, sequence of patterns and clips, mute and solo, cuts, clips on the scheduler, moving, sizing, trimming, splitting, duplicating, hits, head parts. sound-library.test.ts +5: clip offset, end fade, pitched clip, track bus, peaks. - e2e 65 (new) on melektaus-atx: ALL PASSED, 18 checks. e2e 54 and 55 with the fix: ALL PASSED, 33 checks. e2e 4 with 30 asset types: ALL PASSED, 62 checks. - Gate on melektaus-atx: CHECK EXIT 0, 250 test files, 3884 tests. - Counter-checks: no taking up of a clip, no pattern offset, no clip end each fail 3, 1 and 1 tests. - Probe on stein2: the tab with four tracks, before and while playing, looked at in two screenshots. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2a3b90b017
commit
8f9c250eb6
43 files changed
+3863
-36
No files matched your search
@@ -0,0 +1,181 @@
|
||||
import { SetAssetPropertyCommand } from '../../core/commands/assetCommands';
|
||||
import {
|
||||
SONG_GRID_KEY,
|
||||
SONG_GRID_OPTIONS,
|
||||
songLoop,
|
||||
songMeter,
|
||||
songSequence,
|
||||
songTempo,
|
||||
type SongTrack,
|
||||
trackBus,
|
||||
trackLevel,
|
||||
} from '../../core/music/song';
|
||||
import type { Asset, SongAsset } from '../../core/model/Asset';
|
||||
import { textOf } from '../../core/model/Properties';
|
||||
import { Button, Dropdown, Label, Menu } from '../../ui';
|
||||
import { type BusLevel, Music } from '../../viewport/music';
|
||||
import { activateAsset } from '../actions';
|
||||
import { AssetTabEditor } from '../AssetTabEditor';
|
||||
import { claimTransport, releaseTransport } from '../music/transport';
|
||||
import { TOOLBAR_ICON_SVG } from '../panels/toolbarIcons';
|
||||
import type { EditorState } from '../state';
|
||||
import { SongTimeline } from './SongTimeline';
|
||||
|
||||
const INFO_WIDTH = 200;
|
||||
const GRID_WIDTH = 64;
|
||||
|
||||
/**
|
||||
* The Song tab (#334): the tracks and their items, and in the header Play – the Space bar does the
|
||||
* same – and the grid items snap to. The song plays round its loop through the scheduler (#332); an
|
||||
* edit is heard from its next window on, a track's level and pan at once.
|
||||
*/
|
||||
export class SongEditor extends AssetTabEditor<SongAsset, SongTimeline> {
|
||||
/** Public for the check scripts. */
|
||||
readonly playButton: Button;
|
||||
readonly gridButton = new Dropdown('Bar', { width: GRID_WIDTH });
|
||||
readonly music = new Music(() => this.state.project);
|
||||
/** The song 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 song round its loop (Space)',
|
||||
onClick: () => this.togglePlay(),
|
||||
});
|
||||
this.gridButton.onClick = () => this.openGridMenu();
|
||||
const timeline = new SongTimeline(state);
|
||||
timeline.onTogglePlay = () => this.togglePlay();
|
||||
timeline.onStart = (tick) => {
|
||||
if (this.isPlaying) this.music.current?.seek(tick);
|
||||
};
|
||||
timeline.onTracksPreview = (tracks) => {
|
||||
const asset = this.asset();
|
||||
if (asset && this.isPlaying) this.music.setBuses(busesOf(asset.id, tracks));
|
||||
};
|
||||
timeline.onOpenPattern = (patternId) => activateAsset(state, patternId);
|
||||
this.mount(timeline, [this.playButton, new Label('Grid', { style: 'muted' }), this.gridButton]);
|
||||
state.on('load', () => this.stop());
|
||||
}
|
||||
|
||||
override selectedId(): string {
|
||||
return this.state.selectedSongId ?? '';
|
||||
}
|
||||
|
||||
override isMine(asset: Asset): asset is SongAsset {
|
||||
return asset.type === 'song';
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return this.playingId !== '';
|
||||
}
|
||||
|
||||
togglePlay(): void {
|
||||
if (this.isPlaying) this.stop();
|
||||
else this.play();
|
||||
}
|
||||
|
||||
/** Plays the song 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);
|
||||
this.music.setBuses(busesOf(asset.id, asset.tracks));
|
||||
void this.music.play(songSequence(this.state.project, asset), {
|
||||
tempo: songTempo(asset.props),
|
||||
meter: songMeter(asset.props),
|
||||
loop: songLoop(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<AssetTabEditor<SongAsset, SongTimeline>['destroy']>[0]): void {
|
||||
this.music.stop();
|
||||
super.destroy(options);
|
||||
}
|
||||
|
||||
private openGridMenu(): void {
|
||||
const asset = this.asset();
|
||||
if (!asset) return;
|
||||
const current = textOf(asset.props[SONG_GRID_KEY] ?? 'bar');
|
||||
new Menu(
|
||||
SONG_GRID_OPTIONS.map((option) => ({
|
||||
label: option.label,
|
||||
current: option.value === current,
|
||||
onSelect: () =>
|
||||
this.state.execute(new SetAssetPropertyCommand(asset.id, SONG_GRID_KEY, option.value)),
|
||||
})),
|
||||
).openBelow(this.gridButton);
|
||||
}
|
||||
|
||||
private settingsOf(asset: SongAsset): string {
|
||||
return JSON.stringify([songTempo(asset.props), songMeter(asset.props), songLoop(asset)]);
|
||||
}
|
||||
|
||||
/** What changed while playing goes to the scheduler and the tracks; another song shown stops it. */
|
||||
private follow(asset: SongAsset | null): void {
|
||||
if (!this.isPlaying) return;
|
||||
if (!asset || asset.id !== this.playingId) {
|
||||
this.stop();
|
||||
return;
|
||||
}
|
||||
this.music.edit(songSequence(this.state.project, asset));
|
||||
this.music.setBuses(busesOf(asset.id, asset.tracks));
|
||||
const settings = this.settingsOf(asset);
|
||||
if (settings === this.told) return;
|
||||
this.told = settings;
|
||||
const scheduler = this.music.current;
|
||||
scheduler?.setTempo(songTempo(asset.props));
|
||||
scheduler?.setMeter(songMeter(asset.props));
|
||||
scheduler?.setLoop(songLoop(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: SongAsset): string {
|
||||
const tracks = asset.tracks.length;
|
||||
const items = asset.items.length;
|
||||
return `${tracks} ${tracks === 1 ? 'track' : 'tracks'} · ${items} ${items === 1 ? 'item' : 'items'}`;
|
||||
}
|
||||
|
||||
protected override syncExtras(asset: SongAsset | null): void {
|
||||
this.fitButton.setEnabled(asset !== null);
|
||||
this.playButton.setEnabled(asset !== null);
|
||||
this.gridButton.setEnabled(asset !== null);
|
||||
const grid = asset ? textOf(asset.props[SONG_GRID_KEY] ?? 'bar') : 'bar';
|
||||
this.gridButton.setText(SONG_GRID_OPTIONS.find((o) => o.value === grid)?.label ?? 'Bar');
|
||||
this.follow(asset);
|
||||
}
|
||||
}
|
||||
|
||||
/** The level and pan each track's bus is set to. */
|
||||
export function busesOf(songId: string, tracks: readonly SongTrack[]): BusLevel[] {
|
||||
return tracks.map((track) => ({ name: trackBus(songId, track.id), ...trackLevel(track, tracks) }));
|
||||
}
|
||||
Reference in new issue
Block a user