-
Notifications
You must be signed in to change notification settings - Fork 7
feat: add follow actions for session clips and scenes #993
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -9,7 +9,7 @@ import { useSessionDragDrop, type SessionDragState, type SessionDropTarget } fro | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ContextMenuWrapper, ContextMenuSeparator, ContextMenuItem } from '../ui/ContextMenu'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { ColorSwatchPalette } from '../ui/ColorSwatchPalette'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { SessionMixer } from './SessionMixer'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Clip, Track, SessionLaunchQuantization, SessionLaunchMode, SessionClipSlot, SessionPendingLaunch, SessionScene, SceneFollowActionType } from '../../types/project'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import type { Clip, Track, SessionLaunchQuantization, SessionLaunchMode, SessionClipSlot, SessionPendingLaunch, SessionScene, SceneFollowActionType, FollowActionType, FollowActionConfig } from '../../types/project'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const LAUNCH_MODE_OPTIONS: SessionLaunchMode[] = ['trigger', 'gate', 'toggle', 'repeat']; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -80,8 +80,20 @@ interface SlotContextMenuState { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| currentColor: string | null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| legato: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| currentLaunchMode: SessionLaunchMode; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| followAction?: FollowActionConfig; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CLIP_FOLLOW_ACTION_OPTIONS: { value: FollowActionType; label: string }[] = [ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'stop', label: 'Stop' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'again', label: 'Again' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'previous', label: 'Previous' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'next', label: 'Next' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'first', label: 'First' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'last', label: 'Last' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'any', label: 'Any' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| { value: 'other', label: 'Other' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export function SessionView() { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const project = useProjectStore((s) => s.project); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -94,6 +106,8 @@ export function SessionView() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setSessionSlotColor = useProjectStore((s) => s.setSessionSlotColor); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setSessionSlotLegato = useProjectStore((s) => s.setSessionSlotLegato); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setSessionSlotLaunchMode = useProjectStore((s) => s.setSessionSlotLaunchMode); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setSessionSlotFollowAction = useProjectStore((s) => s.setSessionSlotFollowAction); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setSessionFollowActionsEnabled = useProjectStore((s) => s.setSessionFollowActionsEnabled); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const selectedSessionSlot = useUIStore((s) => s.selectedSessionSlot); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setSelectedSessionSlot = useUIStore((s) => s.setSelectedSessionSlot); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const setKeyboardContext = useUIStore((s) => s.setKeyboardContext); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -135,6 +149,20 @@ export function SessionView() { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [colorMenu, setSessionSlotLaunchMode]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleFollowActionChange = useCallback((field: string, value: string | number | boolean) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!colorMenu) return; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Update local state to reflect change immediately | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setColorMenu((prev) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!prev) return null; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ...prev, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, [colorMenu, setSessionSlotFollowAction]); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+152
to
+164
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const handleFollowActionChange = useCallback((field: string, value: string | number | boolean) => { | |
| if (!colorMenu) return; | |
| setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); | |
| // Update local state to reflect change immediately | |
| setColorMenu((prev) => { | |
| if (!prev) return null; | |
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | |
| return { | |
| ...prev, | |
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | |
| }; | |
| }); | |
| }, [colorMenu, setSessionSlotFollowAction]); | |
| const handleFollowActionChange = useCallback( | |
| <K extends keyof FollowActionConfig>(field: K, value: FollowActionConfig[K]) => { | |
| if (!colorMenu) return; | |
| setSessionSlotFollowAction(colorMenu.slotId, { [field]: value }); | |
| // Update local state to reflect change immediately | |
| setColorMenu((prev) => { | |
| if (!prev) return null; | |
| const defaultFA: FollowActionConfig = { actionA: 'next', actionB: 'stop', chanceA: 1, time: 4, enabled: true }; | |
| return { | |
| ...prev, | |
| followAction: { ...(prev.followAction ?? defaultFA), [field]: value }, | |
| }; | |
| }); | |
| }, | |
| [colorMenu, setSessionSlotFollowAction], | |
| ); |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -56,6 +56,7 @@ import type { | |||||
| DrumMachineConfig, | ||||||
| DrumKitName, | ||||||
| SamplerConfig, | ||||||
| FollowActionConfig, | ||||||
| SessionClipSlot, | ||||||
| SessionLaunchEvent, | ||||||
| SessionLaunchMode, | ||||||
|
|
@@ -114,6 +115,7 @@ import { encodeMidiFile, parseMidiFile } from '../utils/midi'; | |||||
| import { encodeMidiFile as encodeMultiTrackMidiFile, type MidiExportTrack } from '../utils/midiEncoder'; | ||||||
| import { clampClipFadeDurations } from '../utils/clipFade'; | ||||||
| import { getSynthPresetById } from '../data/synthPresets'; | ||||||
| import { detectClipGroups, resolveFollowAction, rollFollowAction } from '../utils/followActions'; | ||||||
| import { extractGroove, applyGroove, type ExtractGrooveOptions, type ApplyGrooveOptions } from '../utils/groovePool'; | ||||||
| import type { GrooveTemplate } from '../types/project'; | ||||||
| import { toastError, toastSuccess } from '../hooks/useToast'; | ||||||
|
|
@@ -723,6 +725,9 @@ export interface ProjectState extends MidiSliceActions { | |||||
| stopSessionTrack: (trackId: string) => void; | ||||||
| stopAllSessionClips: () => void; | ||||||
| commitPendingSessionLaunches: (currentTime: number) => void; | ||||||
| setSessionSlotFollowAction: (slotId: string, config: Partial<import('../types/project').FollowActionConfig>) => void; | ||||||
|
||||||
| setSessionSlotFollowAction: (slotId: string, config: Partial<import('../types/project').FollowActionConfig>) => void; | |
| setSessionSlotFollowAction: (slotId: string, config: Partial<FollowActionConfig>) => void; |
Copilot
AI
Mar 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
scheduleFollowAction takes both trackId and currentSlotId, but the track can be derived from the slot. If these ever disagree, group detection will silently fail (detectClipGroups(..., trackId) won’t include the slot). Consider removing the redundant trackId parameter or adding a guard to validate currentSlot.trackId === trackId and early-return with a clear handling path.
Copilot
AI
Mar 27, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
scheduleFollowAction is never invoked from the actual session launch flow (immediate launch, pending launch commit, or follow-action commit). As a result, follow actions won’t fire automatically and follow-action launches won’t chain into subsequent follow actions. Wire scheduling into the place where a clip launch is actually applied (after applySessionTrackLaunch for clip/scene/follow-action executions), and ensure it runs after the launch is committed (calling queuePendingSessionLaunch too early would replace an existing pending launch for the same track).
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -961,6 +961,25 @@ export type SessionLaunchMode = 'trigger' | 'gate' | 'toggle' | 'repeat'; | |
| /** Action to perform automatically when a scene finishes playing. */ | ||
| export type SceneFollowActionType = 'none' | 'next' | 'previous' | 'random' | 'stop'; | ||
|
|
||
| // ─── Follow Action Types ───────────────────────────────────────────────────── | ||
|
|
||
| /** The type of follow action to perform when a clip finishes playing. */ | ||
| export type FollowActionType = 'stop' | 'again' | 'previous' | 'next' | 'first' | 'last' | 'any' | 'other'; | ||
|
|
||
| /** Configuration for a follow action on a session clip slot. */ | ||
| export interface FollowActionConfig { | ||
| /** Primary follow action. */ | ||
| actionA: FollowActionType; | ||
| /** Secondary follow action. */ | ||
| actionB: FollowActionType; | ||
| /** Probability of action A (0-1). Action B probability = 1 - chanceA. */ | ||
| chanceA: number; | ||
| /** Follow action trigger time in beats (e.g., 4 = 1 bar in 4/4). */ | ||
| time: number; | ||
| /** Whether this follow action is enabled. */ | ||
| enabled: boolean; | ||
| } | ||
|
|
||
| export interface SessionScene { | ||
| id: string; | ||
| name: string; | ||
|
|
@@ -990,11 +1009,13 @@ export interface SessionClipSlot { | |
| legato?: boolean; | ||
| /** Clip launch behavior: trigger (default), gate, toggle, or repeat. */ | ||
| launchMode?: SessionLaunchMode; | ||
| /** Follow action configuration for this clip slot. */ | ||
| followAction?: FollowActionConfig; | ||
|
Comment on lines
964
to
+1013
|
||
| } | ||
|
|
||
| export interface SessionPendingLaunch { | ||
| id: string; | ||
| type: 'clip' | 'scene' | 'stop-track' | 'stop-all'; | ||
| type: 'clip' | 'scene' | 'stop-track' | 'stop-all' | 'follow-action'; | ||
| executeAt: number; | ||
| requestedAt: number; | ||
| trackId?: string; | ||
|
|
@@ -1024,6 +1045,8 @@ export interface SessionState { | |
| recordedLaunches: SessionLaunchEvent[]; | ||
| lastLaunchedSceneId: string | null; | ||
| lastLaunchAt: number | null; | ||
| /** Global toggle for follow actions. When false, no follow actions fire. Default true. */ | ||
| followActionsEnabled?: boolean; | ||
| } | ||
|
|
||
| /** A saved project template — a snapshot of project settings and track layout (without audio). */ | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The default follow-action config is duplicated here (
defaultFA) and insetSessionSlotFollowActionin the store. Keeping two sources of truth risks them drifting (e.g., if defaults change later, the UI and store could disagree). Consider exporting a sharedDEFAULT_FOLLOW_ACTION_CONFIGconstant (or a small helper) and reusing it in both places.