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; export class Beat extends EffectScoped { private static count = 0; private barCountInternal: Ref; private globalLoopLengthInternal: Ref; readonly id = Beat.count++; tracks: ShallowRef; barCount: WritableComputedRef; timeSigUp: Ref; globalLoopLength: WritableComputedRef; globalIsLooping: Ref; useAutoBeatLength: Ref; barSettingsLocked: Ref; name: Ref; saveDirty = ref(false); constructor(opts: BeatGroupInitOptions) { super(); this.tracks = shallowRef([]); this.barCountInternal = ref(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(opts.timeSigUp ?? 4); this.globalLoopLengthInternal = ref(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(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): 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 { 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; }