204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
import { deserialise as deserialiseTrack, type TrackInitOptions, isValidTimeSigRange, Track } from "@/Track";
|
|
import { greatestCommonDivisor, isPosInt, EffectScoped } from "@/utils";
|
|
import { watch, computed, ref, shallowRef, triggerRef, type WritableComputedRef, type Ref, type ShallowRef, nextTick } from "vue";
|
|
import { z } from "zod";
|
|
|
|
export interface BeatManager {
|
|
notifyChange(): void;
|
|
}
|
|
|
|
type BeatGroupInitOptions = {
|
|
barCount: number;
|
|
isLooping: boolean;
|
|
timeSigUp: number;
|
|
tracks?: TrackInitOptions[],
|
|
loopLength?: number,
|
|
useAutoBeatLength?: boolean,
|
|
name?: string,
|
|
};
|
|
|
|
const BeatSerialSchema = z.object({
|
|
tracks: z.array(z.unknown()),
|
|
barCount: z.number(),
|
|
timeSigUp: z.number(),
|
|
globalLoopLength: z.number(),
|
|
globalIsLooping: z.boolean(),
|
|
useAutoBeatLength: z.boolean(),
|
|
barSettingsLocked: z.boolean(),
|
|
name: z.string(),
|
|
});
|
|
export type BeatSerial = z.infer<typeof BeatSerialSchema>;
|
|
|
|
export class Beat extends EffectScoped {
|
|
private static count = 0;
|
|
private barCountInternal: Ref<number>;
|
|
private globalLoopLengthInternal: Ref<number>;
|
|
readonly id = Beat.count++;
|
|
|
|
tracks: ShallowRef<Track[]>;
|
|
barCount: WritableComputedRef<number>;
|
|
timeSigUp: Ref<number>;
|
|
globalLoopLength: WritableComputedRef<number>;
|
|
globalIsLooping: Ref<boolean>;
|
|
useAutoBeatLength: Ref<boolean>;
|
|
barSettingsLocked: Ref<boolean>;
|
|
name: Ref<string>;
|
|
saveDirty = ref(false);
|
|
|
|
constructor(opts: BeatGroupInitOptions) {
|
|
super();
|
|
this.tracks = shallowRef<Track[]>([]);
|
|
this.barCountInternal = ref<number>(opts.barCount ?? 4);
|
|
this.barCount = computed({
|
|
get: () => {
|
|
if (this.useAutoBeatLength.value) {
|
|
const loopLengths = [this.timeSigUp.value];
|
|
for (const track of this.tracks.value) {
|
|
if (track.looping.value) {
|
|
const loopLength = track.loopLength.value;
|
|
if (loopLengths.indexOf(loopLength) === -1) {
|
|
loopLengths.push(loopLength);
|
|
}
|
|
}
|
|
}
|
|
const smallestLoopLength = loopLengths.reduce((prev, curr) => (prev * curr) / greatestCommonDivisor(prev, curr), 1);
|
|
return smallestLoopLength / this.timeSigUp.value;
|
|
}
|
|
return this.barCountInternal.value;
|
|
},
|
|
set: (val) => {
|
|
if (this.barSettingsLocked.value || !isPosInt(val)) {
|
|
return;
|
|
}
|
|
this.barCountInternal.value = val;
|
|
},
|
|
});
|
|
this.timeSigUp = ref<number>(opts.timeSigUp ?? 4);
|
|
this.globalLoopLengthInternal = ref<number>(opts.loopLength ?? this.timeSigUp.value);
|
|
this.globalLoopLength = computed({
|
|
get: () => {
|
|
return this.globalLoopLengthInternal.value;
|
|
},
|
|
set: (val) => {
|
|
if (!isPosInt(val)) {
|
|
return;
|
|
}
|
|
this.globalLoopLengthInternal.value = val;
|
|
},
|
|
});
|
|
this.globalIsLooping = ref<boolean>(opts.isLooping ?? false);
|
|
this.useAutoBeatLength = ref(opts.useAutoBeatLength ?? false);
|
|
this.barSettingsLocked = computed(() => this.useAutoBeatLength.value);
|
|
this.name = ref(opts.name ?? `Beat-${ this.id }`);
|
|
|
|
watch([this.barCount, this.timeSigUp, this.name], ([newBarCount, newTimeSigUp]) => {
|
|
for (const track of this.tracks.value) {
|
|
track.barCount.value = newBarCount;
|
|
track.timeSigUp.value = newTimeSigUp;
|
|
}
|
|
this.saveDirty.value = true;
|
|
}, { immediate: true });
|
|
|
|
nextTick(() => this.saveDirty.value = false);
|
|
}
|
|
|
|
setTimeSigUp(timeSigVal: number): void {
|
|
if (!isValidTimeSigRange(timeSigVal)) {
|
|
timeSigVal = this.timeSigUp.value;
|
|
}
|
|
this.timeSigUp.value = timeSigVal;
|
|
for (const track of this.tracks.value) {
|
|
track.timeSigUp.value = timeSigVal;
|
|
}
|
|
}
|
|
|
|
getTrackByIndex(trackIndex: number) {
|
|
const track = this.tracks.value[trackIndex];
|
|
if (!track) {
|
|
throw new Error(`Could not find the track with index: ${trackIndex}`);
|
|
}
|
|
return track;
|
|
}
|
|
|
|
insertAt(trackIndex: number, newIndex: number): void {
|
|
const track = this.getTrackByIndex(trackIndex);
|
|
this.tracks.value.splice(trackIndex, 1);
|
|
this.tracks.value = this.tracks.value.slice(0, newIndex).concat([track]).concat(this.tracks.value.slice(newIndex));
|
|
triggerRef(this.tracks);
|
|
}
|
|
|
|
notifyChange() {
|
|
this.saveDirty.value = true;
|
|
}
|
|
|
|
addTrack(options?: Omit<TrackInitOptions, 'manager'>): Track | null {
|
|
const optionsResolved = {
|
|
manager: this,
|
|
barCount: this.barCount.value,
|
|
isLooping: this.globalIsLooping.value,
|
|
loopLength: this.globalLoopLength.value,
|
|
...options,
|
|
timeSig: {
|
|
up: this.timeSigUp.value,
|
|
down: 4,
|
|
...options?.timeSig ?? {},
|
|
},
|
|
} satisfies TrackInitOptions;
|
|
const newTrack = Track.asScoped(optionsResolved) ?? null;
|
|
if (newTrack) {
|
|
this.tracks.value.push(newTrack);
|
|
triggerRef(this.tracks);
|
|
}
|
|
return newTrack;
|
|
}
|
|
|
|
removeTrack(index: number): void {
|
|
const track = this.getTrackByIndex(index);
|
|
this.tracks.value.splice(index, 1);
|
|
track.destroy();
|
|
triggerRef(this.tracks);
|
|
}
|
|
|
|
bakeLoops(): void {
|
|
const barCountLooped = this.barCount.value;
|
|
this.useAutoBeatLength.value = false;
|
|
this.barCount.value = barCountLooped;
|
|
this.tracks.value.forEach(track => track.bakeLoops());
|
|
}
|
|
|
|
serialise(): Readonly<BeatSerial> {
|
|
return {
|
|
tracks: this.tracks.value.map(track => track.serialise()),
|
|
barCount: this.barCount.value,
|
|
timeSigUp: this.timeSigUp.value,
|
|
globalLoopLength: this.globalLoopLength.value,
|
|
globalIsLooping: this.globalIsLooping.value,
|
|
useAutoBeatLength: this.useAutoBeatLength.value,
|
|
barSettingsLocked: this.barSettingsLocked.value,
|
|
name: this.name.value,
|
|
} as const;
|
|
}
|
|
}
|
|
|
|
export function deserialise(serial: unknown): Beat | null {
|
|
const parse = BeatSerialSchema.safeParse(serial);
|
|
if (!parse.success) {
|
|
console.error('Encountered invalid beat serial:', parse.error, serial);
|
|
return null;
|
|
}
|
|
const beat = parse.data;
|
|
const newBeat = Beat.asScoped({
|
|
loopLength: beat.globalLoopLength,
|
|
barCount: beat.barCount,
|
|
isLooping: beat.globalIsLooping,
|
|
name: beat.name,
|
|
timeSigUp: beat.timeSigUp,
|
|
useAutoBeatLength: beat.useAutoBeatLength,
|
|
});
|
|
beat.tracks.forEach(trackSerial => {
|
|
const track = deserialiseTrack(trackSerial, newBeat);
|
|
if (track) newBeat.tracks.value.push(track);
|
|
});
|
|
return newBeat;
|
|
}
|