/** * The clips of a sound (#357): stretches of its file with a name and a cut of their own, named by a * sound field the way a frame is by a texture field. The sound itself is the whole file. */ import { describe, expect, it } from 'vitest'; import { CreateAssetCommand, CreateSoundCommand, DuplicateAssetCommand, } from '../../src/core/commands/assetCommands'; import { AddClipCommand, RemoveClipCommand, RenameClipCommand, SetClipPropsCommand, } from '../../src/core/commands/clipCommands'; import { SetSongCommand } from '../../src/core/commands/songCommands'; import type { SoundAsset } from '../../src/core/model/Asset'; import { findAsset, findAssetOf } from '../../src/core/model/Project'; import { clipsOf, findSound } from '../../src/core/model/sound'; import { embeddedCopy, embeddedUsedBy } from '../../src/core/music/embedded'; import { sequenceNeeds } from '../../src/core/music/needs'; import { itemFx, newItem, songSequence } from '../../src/core/music/song'; import { assetRefLabel, soundChoices } from '../../src/core/properties/assetRef'; import { playedSoundById } from '../../src/core/properties/playedSound'; import { referencedAssetId } from '../../src/core/properties/references'; import { END_KEY, START_KEY } from '../../src/core/properties/sounds'; import { deserializeProject, serializeProject } from '../../src/core/store/serialize'; import { clipAt } from '../../src/editor/sound/waveView'; import { makeProject } from '../helpers'; const FILE = `${'d5'.repeat(32)}.wav`; const BAR = 1920; /** A sound "Kick" with two clips, and a song with a take of its own that has one clip. */ function setup() { const f = makeProject(); const kick = new CreateSoundCommand('Kick', FILE); f.stack.execute(kick); const first = new AddClipCommand(kick.asset.id, { [START_KEY]: 0.5, [END_KEY]: 0.75 }); const second = new AddClipCommand(kick.asset.id, { [START_KEY]: 1 }); f.stack.execute(first); f.stack.execute(second); const made = new CreateAssetCommand('song', 'Theme'); f.stack.execute(made); const songId = made.asset.id; const song = () => findAssetOf(f.project, songId, 'song')!; const take = new CreateSoundCommand('Take 1', FILE, songId); f.stack.execute(take); const takeClip = new AddClipCommand(take.asset.id, { [START_KEY]: 2, [END_KEY]: 3 }); f.stack.execute(takeClip); const track = song().tracks[0]!.id; const item = newItem({ track, kind: 'sound', asset: takeClip.clipId, tick: 0, length: BAR }); f.stack.execute(new SetSongCommand(songId, { items: [item] })); return { f, song, songId, track, kick: () => findAssetOf(f.project, kick.asset.id, 'sound')!, firstId: first.clipId, secondId: second.clipId, takeId: take.asset.id, takeClipId: takeClip.clipId, }; } describe('a clip of a sound (#357)', () => { it('is named "Clip 1", 2 … per sound, and resolves to its sound', () => { const { f, kick, firstId, secondId, takeId, takeClipId } = setup(); expect(clipsOf(kick()).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']); expect(findSound(f.project, secondId)).toEqual({ asset: kick(), clip: clipsOf(kick())[1] }); expect(findSound(f.project, kick().id)).toEqual({ asset: kick(), clip: null }); expect(findSound(f.project, takeClipId)?.asset.id).toBe(takeId); expect(findSound(f.project, 'nothing')).toBeNull(); expect(findSound(f.project, f.project.assets.find((a) => a.type === 'song')!.id)).toBeNull(); expect(firstId).not.toBe(secondId); }); it('plays its stretch, the sound the whole file', () => { const { f, kick, firstId } = setup(); expect(playedSoundById(f.project, kick().id)).toBe(FILE); expect(playedSoundById(f.project, firstId)).toBe(`${FILE}#0.5~0.75~0.005~0.005~linear~linear`); }); it('reads "Sound / Clip" wherever a field names it, and is offered after its sound', () => { const { f, kick, firstId, secondId, takeClipId } = setup(); expect(assetRefLabel(f.project, firstId)).toBe('Kick / Clip 1'); expect(assetRefLabel(f.project, takeClipId)).toBe('• Take 1 / Clip 1'); expect(assetRefLabel(f.project, 'gone')).toBe('gone?'); // What a song keeps to itself is offered nowhere, nor are its clips. expect(soundChoices(f.project)).toEqual([ { id: kick().id, label: 'Kick' }, { id: firstId, label: 'Kick / Clip 1' }, { id: secondId, label: 'Kick / Clip 2' }, ]); expect( referencedAssetId(f.project, { key: 'sound', label: 'Sound', kind: 'sound', default: '' }, firstId), ).toBe(kick().id); }); it('is set, renamed and deleted in steps an undo takes back', () => { const { f, kick, firstId, secondId } = setup(); f.stack.execute(new SetClipPropsCommand(kick().id, firstId, { [START_KEY]: 0.25, [END_KEY]: 0.5 })); expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.25, [END_KEY]: 0.5 }); f.stack.undo(); expect(clipsOf(kick())[0]!.props).toEqual({ [START_KEY]: 0.5, [END_KEY]: 0.75 }); f.stack.execute(new RenameClipCommand(kick().id, secondId, 'Clip 1')); expect(clipsOf(kick())[1]!.name).toBe('Clip 1 2'); f.stack.undo(); f.stack.execute(new RemoveClipCommand(kick().id, firstId)); expect(clipsOf(kick()).map((c) => c.id)).toEqual([secondId]); expect(playedSoundById(f.project, firstId)).toBe(''); f.stack.undo(); expect(clipsOf(kick()).map((c) => c.id)).toEqual([firstId, secondId]); }); it('refuses a value a clip cannot hold', () => { const { f, kick, firstId } = setup(); const before = structuredClone(clipsOf(kick())); const set = (values: Record) => () => f.stack.execute(new SetClipPropsCommand(kick().id, firstId, values)); expect(set({ volume: 1 })).toThrow('A clip has no "volume"'); expect(set({ [START_KEY]: -1 })).toThrow('Start (s) needs a number of at least 0'); expect(set({ [START_KEY]: Number.NaN })).toThrow('Start (s) needs a number of at least 0'); expect(set({ fadeInCurve: 'wobbly' })).toThrow('Fade in curve has no "wobbly"'); expect(() => f.stack.execute(new RenameClipCommand(kick().id, firstId, ' '))).toThrow( 'A clip needs a name', ); expect(clipsOf(kick())).toEqual(before); }); it('survives a round trip; a clip repeating an id the document has is dropped', () => { const { f, kick, firstId } = setup(); expect(deserializeProject(serializeProject(f.project))).toEqual(f.project); const raw = serializeProject(f.project) as { assets: { id: string; clips?: unknown[] }[] }; const stored = raw.assets.find((a) => a.id === kick().id)!; stored.clips = [ ...stored.clips!, { id: firstId, name: 'Twin', props: {} }, { id: kick().id, name: 'Sound', props: {} }, { id: 'odd', name: 'Odd', props: { start: 1, volume: 0.5 } }, ]; const read = findAssetOf(deserializeProject(raw), kick().id, 'sound')!; expect(clipsOf(read).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2', 'Odd']); expect(clipsOf(read)[2]!.props).toEqual({ start: 1 }); }); it('gets a fresh id in a duplicate of its sound', () => { const { f, kick, firstId, secondId } = setup(); const duplicate = new DuplicateAssetCommand(kick().id); f.stack.execute(duplicate); const copy = findAssetOf(f.project, duplicate.copyId, 'sound')!; expect(clipsOf(copy).map((c) => c.name)).toEqual(['Clip 1', 'Clip 2']); expect(clipsOf(copy).map((c) => c.id)).not.toContain(firstId); expect(clipsOf(copy).map((c) => c.id)).not.toContain(secondId); expect(findSound(f.project, firstId)?.asset.id).toBe(kick().id); }); }); describe('a clip in a song (#357)', () => { it('is played by its item, through the FX of its sound', () => { const { f, song, takeClipId } = setup(); const take = findSound(f.project, takeClipId)!.asset; take.props.fx = 'some-fx'; expect(songSequence(f.project, song()).clips?.map((c) => c.sound)).toEqual([takeClipId]); expect(sequenceNeeds(f.project, songSequence(f.project, song())).files).toEqual([ `${FILE}#2~3~0.005~0.005~linear~linear`, ]); expect(itemFx(f.project, song().items[0]!)).toBe('some-fx'); }); it('keeps its take, which a second item shares, on the same clip or the whole take', () => { const { f, song, songId, track, takeId, takeClipId } = setup(); expect(embeddedUsedBy(f.project, song().items).map((a) => a.id)).toEqual([takeId]); const before = f.project.assets.length; const [item] = song().items; const second = { ...item!, id: 'second', tick: BAR }; f.stack.execute(new SetSongCommand(songId, { items: [item!, second] })); expect(song().items.map((i) => i.asset)).toEqual([takeClipId, takeClipId]); f.stack.execute(new SetSongCommand(songId, { items: [item!, { ...second, asset: takeId }] })); expect(song().items.map((i) => i.asset)).toEqual([takeClipId, takeId]); expect(f.project.assets.length).toBe(before); expect(song().items.map((i) => i.track)).toEqual([track, track]); }); it('is copied along when an item is given a copy of the sound or the song is duplicated', () => { const { f, song, kick, secondId, takeClipId } = setup(); const { copy, ids } = embeddedCopy(kick(), song()); const copied = clipsOf(copy as SoundAsset); expect(ids.get(secondId)).toBe(copied[1]!.id); expect(ids.get(kick().id)).toBe(copy.id); const duplicate = new DuplicateAssetCommand(song().id); f.stack.execute(duplicate); const twin = findAssetOf(f.project, duplicate.copyId, 'song')!; const named = twin.items[0]!.asset; expect(named).not.toBe(takeClipId); const found = findSound(f.project, named)!; expect([found.asset.owner, found.clip?.name]).toEqual([twin.id, 'Clip 1']); expect(findAsset(f.project, found.asset.id)).toBe(found.asset); }); }); describe('the strip of the Sound tab (#357)', () => { it('picks the band under the pointer, the one drawn last where they lie over each other', () => { const view = { panX: 0, pxPerSecond: 100 }; const bands = [ { id: 'a', span: { start: 0, end: 1 } }, { id: 'b', span: { start: 0.5, end: 2 } }, ]; expect([10, 60, 150, 250].map((x) => clipAt(view, bands, x))).toEqual(['a', 'b', 'b', null]); }); });