Pick a pattern's sign in one row, as a song's (#364)
game-maker / gate (push) Successful in 3m52s
game-maker / suite (push) Successful in 15m0s

A pattern had two rows for its meter, Beats per bar and Beat, while a
song has had one row Sign since #363. Both now pick from the same list.

- The list of signs and how a sign becomes a meter moved to time.ts.
  Song and pattern share it.
- A pattern keeps its meter in `sign`. The fields `beats` and `beat`
  are gone, a sign that is not in the list counts as 4/4.
- Create pattern in a song hands the song's sign to the new pattern.
- The four patterns of cbbf160bb7e8 were moved to `sign` by hand
  (three 4/4, one 3/4). The other projects only had 4/4, which stays
  the default.

Tests:
- Unit pattern/embedded: 30 green (1 new, 2 rewritten), the
  counter-test reading `beats` again turns 2 red
- e2e 64 + 65 (melektaus-atx): ALL PASSED, 85 checks
- Gate (melektaus-atx): CHECK EXIT 0, 272 files, 4215 tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5.5 committed 2026-10-09 08:15:14 +02:00
1 parent 0d57a2f3c4
commit 8891f027db
9 files changed
+60 -67

No files matched your search

+3 -7
View File
@@ -15,8 +15,9 @@ import { findAsset, findAssetOf, type Project } from '../model/Project';
import { assetNamedBy, renewClipIds } from '../model/sound';
import { ASSET_FX_KEY, assetFxOf, NO_FX } from '../properties/assetFx';
import { nextNumberedName } from '../commands/naming';
import { PATTERN_BEAT_KEY, PATTERN_BEATS_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern';
import { PATTERN_SIGN_KEY, PATTERN_TEMPO_KEY, patternInstrument } from './pattern';
import { type SongItem, songMeter, songTempo, type SongTrack } from './song';
import { signOf } from './time';
export const EMBEDDABLE_TYPES: readonly AssetType[] = ['sound', 'pattern', 'soundfx'];
@@ -71,12 +72,7 @@ export function assetTitle(project: Project, asset: Asset): string {
/** The tempo and the meter of a song, as the props a pattern made in it starts with. */
export function songClockProps(props: PropertyBag): PropertyBag {
const meter = songMeter(props);
return {
[PATTERN_TEMPO_KEY]: songTempo(props),
[PATTERN_BEATS_KEY]: meter.beats,
[PATTERN_BEAT_KEY]: String(meter.unit),
};
return { [PATTERN_TEMPO_KEY]: songTempo(props), [PATTERN_SIGN_KEY]: signOf(songMeter(props)) };
}
/**
+3 -8
View File
@@ -8,7 +8,7 @@ import { HIGHEST_NOTE, LOWEST_NOTE } from '../instrument/notes';
import type { PatternAsset } from '../model/Asset';
import { type PropertyBag, textOf } from '../model/Properties';
import { heldLoop, type LoopRange, type Sequence } from './sequence';
import { heldMeter, heldTempo, type Meter, ticksPerBar } from './time';
import { DEFAULT_SIGN, heldTempo, type Meter, signMeter, ticksPerBar } from './time';
export interface PatternNote {
id: string;
@@ -24,8 +24,7 @@ export interface PatternNote {
export const PATTERN_INSTRUMENT_KEY = 'instrument';
export const PATTERN_BARS_KEY = 'bars';
export const PATTERN_GRID_KEY = 'grid';
export const PATTERN_BEATS_KEY = 'beats';
export const PATTERN_BEAT_KEY = 'beat';
export const PATTERN_SIGN_KEY = 'sign';
export const PATTERN_TEMPO_KEY = 'tempo';
export const DEFAULT_BARS = 4;
@@ -49,7 +48,6 @@ const GRIDS: readonly (readonly [string, number])[] = [
];
export const GRID_OPTIONS = GRIDS.map(([value]) => ({ value, label: value }));
export const BEAT_OPTIONS = [1, 2, 4, 8, 16, 32].map((unit) => ({ value: String(unit), label: `1/${unit}` }));
export function gridTicks(grid: string): number {
return (GRIDS.find(([value]) => value === grid) ?? GRIDS[4]!)[1];
@@ -66,10 +64,7 @@ function wholeNumber(
}
export function patternMeter(props: PropertyBag): Meter {
return heldMeter({
beats: wholeNumber(props[PATTERN_BEATS_KEY], 4, 1, 32),
unit: Number(textOf(props[PATTERN_BEAT_KEY] ?? '4')),
});
return signMeter(textOf(props[PATTERN_SIGN_KEY] ?? DEFAULT_SIGN));
}
export function patternTempo(props: PropertyBag): number {
+2 -25
View File
@@ -14,7 +14,7 @@ import { findSound } from '../model/sound';
import { pickedFx } from '../properties/assetFx';
import { gridTicks, GRID_OPTIONS, patternInstrument, patternLength } from './pattern';
import { heldLoop, type LoopRange, type Sequence, type SequenceClip, type SequenceNote } from './sequence';
import { heldMeter, heldTempo, type Meter, secondsOfTicks, ticksPerBar } from './time';
import { DEFAULT_SIGN, heldTempo, type Meter, secondsOfTicks, signMeter, ticksPerBar } from './time';
export interface SongTrack {
id: string;
@@ -59,26 +59,6 @@ export const SONG_TEMPO_KEY = 'tempo';
export const SONG_SIGN_KEY = 'sign';
export const SONG_GRID_KEY = 'grid';
/** The time signatures a song is written in (#363), one row for both numbers. */
export const SIGNS: readonly string[] = [
'1/2',
'2/2',
'3/2',
'1/4',
'2/4',
'3/4',
'4/4',
'5/4',
'6/4',
'5/8',
'6/8',
'7/8',
'8/8',
'9/8',
];
export const DEFAULT_SIGN = '4/4';
export const SIGN_OPTIONS = SIGNS.map((sign) => ({ value: sign, label: sign }));
export const DEFAULT_SONG_GRID = 'bar';
export const SONG_GRID_OPTIONS = [{ value: 'bar', label: 'Bar' }, ...GRID_OPTIONS];
/** A new song has this many tracks. */
@@ -97,11 +77,8 @@ export function itemMaxDb(kind: SongItemKind): number {
return kind === 'sound' ? SOUND_ITEM_MAX_DB : 0;
}
/** The meter of a song's sign; 4/4 where it is not one of `SIGNS`. */
export function songMeter(props: PropertyBag): Meter {
const sign = textOf(props[SONG_SIGN_KEY] ?? DEFAULT_SIGN);
const [beats, unit] = (SIGNS.includes(sign) ? sign : DEFAULT_SIGN).split('/');
return heldMeter({ beats: Number(beats), unit: Number(unit) });
return signMeter(textOf(props[SONG_SIGN_KEY] ?? DEFAULT_SIGN));
}
export function songTempo(props: PropertyBag): number {
+30
View File
@@ -28,6 +28,36 @@ export function heldMeter(meter: Meter): Meter {
return { beats, unit };
}
/** The time signatures a song or a pattern is written in (#363, #364), one row for both numbers. */
export const SIGNS: readonly string[] = [
'1/2',
'2/2',
'3/2',
'1/4',
'2/4',
'3/4',
'4/4',
'5/4',
'6/4',
'5/8',
'6/8',
'7/8',
'8/8',
'9/8',
];
export const DEFAULT_SIGN = '4/4';
export const SIGN_OPTIONS = SIGNS.map((sign) => ({ value: sign, label: sign }));
/** The meter of a sign; 4/4 where it is not one of `SIGNS`. */
export function signMeter(sign: string): Meter {
const [beats, unit] = (SIGNS.includes(sign) ? sign : DEFAULT_SIGN).split('/');
return heldMeter({ beats: Number(beats), unit: Number(unit) });
}
export function signOf(meter: Meter): string {
return `${meter.beats}/${meter.unit}`;
}
/** A tempo between `MIN_TEMPO` and `MAX_TEMPO`; 0 and anything that is not a number is the default. */
export function heldTempo(tempo: number): number {
if (!Number.isFinite(tempo) || tempo <= 0) return DEFAULT_TEMPO;
+3 -17
View File
@@ -105,28 +105,24 @@ import { SHADER_KEY, shaderOf, shaderParamDefs } from './shaders';
import { SHADER_RESOLUTION_KEY, SHADER_RESOLUTION_OPTIONS } from './cameraShader';
import { LOOP_KEY as SOUND_LOOP_KEY, VOLUME_KEY as SOUND_VOLUME_KEY } from './sounds';
import {
BEAT_OPTIONS,
DEFAULT_BARS,
DEFAULT_GRID,
GRID_OPTIONS,
MAX_BARS,
PATTERN_BARS_KEY,
PATTERN_BEAT_KEY,
PATTERN_BEATS_KEY,
PATTERN_GRID_KEY,
PATTERN_INSTRUMENT_KEY,
PATTERN_SIGN_KEY,
PATTERN_TEMPO_KEY,
} from '../music/pattern';
import {
DEFAULT_SIGN,
DEFAULT_SONG_GRID,
SIGN_OPTIONS,
SONG_GRID_KEY,
SONG_GRID_OPTIONS,
SONG_SIGN_KEY,
SONG_TEMPO_KEY,
} from '../music/song';
import { DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO } from '../music/time';
import { DEFAULT_SIGN, DEFAULT_TEMPO, MAX_TEMPO, MIN_TEMPO, SIGN_OPTIONS } from '../music/time';
import { ASSET_FX_KEY } from './assetFx';
import { BUS_FX_KEY, BUS_INTO_KEY, BUS_KEY, BUS_VOLUME_KEY } from './buses';
import { Theme } from '../../ui';
@@ -737,17 +733,7 @@ const ASSET_SCHEMAS: Record<AssetType, PropertySchema> = {
decimals: 0,
},
{ key: PATTERN_GRID_KEY, label: 'Grid', kind: 'enum', default: DEFAULT_GRID, options: GRID_OPTIONS },
{
key: PATTERN_BEATS_KEY,
label: 'Beats per bar',
kind: 'number',
default: 4,
min: 1,
max: 32,
step: 1,
decimals: 0,
},
{ key: PATTERN_BEAT_KEY, label: 'Beat', kind: 'enum', default: '4', options: BEAT_OPTIONS },
{ key: PATTERN_SIGN_KEY, label: 'Sign', kind: 'enum', default: DEFAULT_SIGN, options: SIGN_OPTIONS },
{
key: PATTERN_TEMPO_KEY,
label: 'Tempo (BPM)',
+2 -3
View File
@@ -311,7 +311,7 @@ describe('a pattern made in a song (#355)', () => {
it('hides only its bus, not its clock, length, instrument or sound FX (#363)', () => {
const { make } = withPatterns();
const own = make();
const keys = ['bus', 'tempo', 'beats', 'beat', 'bars', 'instrument', 'fx'];
const keys = ['bus', 'tempo', 'sign', 'bars', 'instrument', 'fx'];
expect(keys.map((k) => isRowHiddenWhenEmbedded(own, k))).toEqual([
true,
false,
@@ -319,7 +319,6 @@ describe('a pattern made in a song (#355)', () => {
false,
false,
false,
false,
]);
});
@@ -348,7 +347,7 @@ describe('a pattern made in a song (#355)', () => {
expect([patternTempo(copy.props), patternMeter(copy.props).beats]).toEqual([120, 4]);
const other = new CreateAssetCommand('song', 'Other');
f.stack.execute(other);
const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1, beats: 5 }, other.asset.id);
const theirs = new CreateAssetCommand('pattern', 'Theirs', { bars: 1, sign: '5/4' }, other.asset.id);
f.stack.execute(theirs);
edit([
...song().items,
+15 -4
View File
@@ -16,6 +16,7 @@ import {
patternSequence,
patternTempo,
} from '../../src/core/music/pattern';
import { SIGN_OPTIONS } from '../../src/core/music/time';
import { schemaForAsset } from '../../src/core/properties/PropertySchema';
import { makeProject, roundTrip } from '../helpers';
@@ -52,8 +53,7 @@ describe('pattern asset (#333)', () => {
'instrument',
'bars',
'grid',
'beats',
'beat',
'sign',
'tempo',
'fx',
'bus',
@@ -70,11 +70,22 @@ describe('pattern asset (#333)', () => {
it('measures its length in bars of its meter: 3 bars of 6/8 are 4320 ticks', () => {
const f = makePattern();
f.set('bars', 3);
f.set('beats', 6);
f.set('beat', '8');
f.set('sign', '6/8');
expect(patternLength(f.pattern())).toBe(3 * 6 * 240);
});
it('picks its meter from the signs a song has too, 4/4 for any other (#364)', () => {
const f = makePattern();
f.set('sign', '9/8');
expect(patternMeter(f.pattern().props)).toEqual({ beats: 9, unit: 8 });
f.set('sign', '12/8');
expect(patternMeter(f.pattern().props)).toEqual({ beats: 4, unit: 4 });
expect(schemaForAsset(f.project, f.pattern()).find((d) => d.key === 'sign')).toMatchObject({
kind: 'enum',
options: SIGN_OPTIONS,
});
});
it('holds notes to whole ticks, a tick at least, the keyboard and 0 to 1, sorted, ids unique', () => {
const held = heldPatternNotes([
{ id: 'b', tick: 480.4, length: 0.2, note: 64, velocity: 2 },
+1 -2
View File
@@ -492,8 +492,7 @@ export async function pattern(t) {
const p = __gm.state.project.assets.find((a) => a.id === ${JSON.stringify(id)});
p.props.tempo = 150;
p.props.bars = 1;
p.props.beats = 4;
p.props.beat = '4';
p.props.sign = '4/4';
p.notes = [60, 62, 64, 65].map((note, i) => ({ id: 'pt64-n' + i, tick: i * 480, length: 240, note, velocity: 1 }));`);
await showTab('scene');
const rig = await scaffoldBehavior(t, {
+1 -1
View File
@@ -1167,7 +1167,7 @@ export async function song(t) {
madePattern.props.bars === 1 &&
madePattern.props.instrument === 'sg65-ins' &&
madePattern.props.tempo === 120 &&
madePattern.props.beats === 4 &&
madePattern.props.sign === '4/4' &&
madeTab.tab === 'pattern' &&
madeTab.title === `${songName} › Pattern 1` &&
madeTab.row === null,