From 8ccb036c901bc1e50287e480bd4979043d8ad336 Mon Sep 17 00:00:00 2001 From: ChuxiJ Date: Fri, 27 Mar 2026 14:20:18 +0800 Subject: [PATCH] feat: add integrated mixer panel in session view Add a collapsible mixer strip below the session grid showing per-track volume fader, pan knob, solo/mute buttons, and level meters. The mixer can be toggled via a button at the bottom of the session view. - SessionMixerStrip: horizontal strip with fader, pan knob, S/M buttons, meter - SessionMixer: container rendering strips for all tracks with toggle - Integrated below the clip grid in SessionView - 15 unit tests covering rendering, interaction, and store updates Closes #925 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/session/SessionMixer.tsx | 76 +++++ src/components/session/SessionMixerStrip.tsx | 196 +++++++++++++ src/components/session/SessionView.tsx | 7 + .../session/__tests__/SessionMixer.test.tsx | 269 ++++++++++++++++++ 4 files changed, 548 insertions(+) create mode 100644 src/components/session/SessionMixer.tsx create mode 100644 src/components/session/SessionMixerStrip.tsx create mode 100644 src/components/session/__tests__/SessionMixer.test.tsx diff --git a/src/components/session/SessionMixer.tsx b/src/components/session/SessionMixer.tsx new file mode 100644 index 00000000..846d2ba9 --- /dev/null +++ b/src/components/session/SessionMixer.tsx @@ -0,0 +1,76 @@ +import { useCallback } from 'react'; +import { useProjectStore } from '../../store/projectStore'; +import { SessionMixerStrip } from './SessionMixerStrip'; + +interface SessionMixerProps { + visible: boolean; + onToggle: () => void; +} + +export function SessionMixer({ visible, onToggle }: SessionMixerProps) { + const project = useProjectStore((s) => s.project); + const updateTrack = useProjectStore((s) => s.updateTrack); + + const handleVolumeChange = useCallback((trackId: string, volume: number) => { + updateTrack(trackId, { volume }); + }, [updateTrack]); + + const handlePanChange = useCallback((trackId: string, pan: number) => { + // updateTrackMixer handles pan updates + useProjectStore.getState().updateTrackMixer(trackId, { pan }); + }, []); + + const handleMuteToggle = useCallback((trackId: string, currentMuted: boolean) => { + updateTrack(trackId, { muted: !currentMuted }); + }, [updateTrack]); + + const handleSoloToggle = useCallback((trackId: string, currentSoloed: boolean) => { + updateTrack(trackId, { soloed: !currentSoloed }); + }, [updateTrack]); + + if (!project) return null; + + const tracks = [...project.tracks].sort((a, b) => a.order - b.order); + + return ( +
+ {/* Toggle button */} +
+ +
+ + {/* Mixer strips container */} +
+ {tracks.map((track) => ( + handleVolumeChange(track.id, v)} + onPanChange={(v) => handlePanChange(track.id, v)} + onMuteToggle={() => handleMuteToggle(track.id, track.muted)} + onSoloToggle={() => handleSoloToggle(track.id, track.soloed)} + /> + ))} +
+
+ ); +} diff --git a/src/components/session/SessionMixerStrip.tsx b/src/components/session/SessionMixerStrip.tsx new file mode 100644 index 00000000..7765a206 --- /dev/null +++ b/src/components/session/SessionMixerStrip.tsx @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { getAudioEngine } from '../../hooks/useAudioEngine'; +import { Knob } from '../ui/Knob'; + +/** Convert linear level (0..1+) to a 0..1 fill fraction mapping -60dB..0dB */ +function levelToFill(linear: number): number { + if (linear <= 0) return 0; + const db = 20 * Math.log10(linear); + return Math.max(0, Math.min(1, (db + 60) / 60)); +} + +export interface SessionMixerStripProps { + trackId: string; + trackName: string; + trackColor: string; + volume: number; + pan: number; + muted: boolean; + soloed: boolean; + onVolumeChange?: (volume: number) => void; + onPanChange?: (pan: number) => void; + onMuteToggle?: () => void; + onSoloToggle?: () => void; +} + +export function SessionMixerStrip({ + trackId, + trackName, + trackColor, + volume, + pan, + muted, + soloed, + onVolumeChange, + onPanChange, + onMuteToggle, + onSoloToggle, +}: SessionMixerStripProps) { + const rafRef = useRef(0); + const [leftFill, setLeftFill] = useState(0); + const [rightFill, setRightFill] = useState(0); + const containerRef = useRef(null); + const dragging = useRef(false); + + // Animate meter levels + useEffect(() => { + const engine = getAudioEngine(); + const tick = () => { + const meter = engine.getTrackMeter(trackId); + setLeftFill(levelToFill(meter.leftLevel)); + setRightFill(levelToFill(meter.rightLevel)); + rafRef.current = requestAnimationFrame(tick); + }; + rafRef.current = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafRef.current); + }, [trackId]); + + // Fader drag logic + const getVolumeFromX = useCallback((clientX: number) => { + const el = containerRef.current; + if (!el) return volume; + const rect = el.getBoundingClientRect(); + const ratio = (clientX - rect.left) / rect.width; + return Math.max(0, Math.min(1, ratio)); + }, [volume]); + + const onPointerDown = useCallback((e: React.PointerEvent) => { + e.preventDefault(); + dragging.current = true; + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); + onVolumeChange?.(getVolumeFromX(e.clientX)); + }, [getVolumeFromX, onVolumeChange]); + + const onPointerMove = useCallback((e: React.PointerEvent) => { + if (!dragging.current) return; + onVolumeChange?.(getVolumeFromX(e.clientX)); + }, [getVolumeFromX, onVolumeChange]); + + const onPointerUp = useCallback(() => { + dragging.current = false; + }, []); + + const onDoubleClick = useCallback(() => { + onVolumeChange?.(0.8); + }, [onVolumeChange]); + + const faderPct = volume * 100; + + return ( +
+ {/* Track color accent */} +
+ + {/* Track name (truncated) */} +
+ {trackName} +
+ + {/* Volume fader with meter */} +
+ {/* Meter bars */} +
+
+
+
+
+
+
+
+ + {/* Fader position indicator */} +
+
+ + {/* Pan knob */} +
+ onPanChange?.(v)} + label="Pan" + size={24} + step={0.01} + /> +
+ + {/* Solo button */} + + + {/* Mute button */} + +
+ ); +} diff --git a/src/components/session/SessionView.tsx b/src/components/session/SessionView.tsx index 7942d014..87cb684c 100644 --- a/src/components/session/SessionView.tsx +++ b/src/components/session/SessionView.tsx @@ -8,6 +8,7 @@ import { getSessionClips } from '../../utils/sessionClips'; import { useSessionDragDrop, type SessionDragState, type SessionDropTarget } from '../../hooks/useSessionDragDrop'; 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'; const LAUNCH_MODE_OPTIONS: SessionLaunchMode[] = ['trigger', 'gate', 'toggle', 'repeat']; @@ -109,6 +110,7 @@ export function SessionView() { const [colorMenu, setColorMenu] = useState(null); const [sceneMenu, setSceneMenu] = useState(null); const { dragState, dropTarget, handlePointerDown, handlePointerMove, handlePointerUp, cancelDrag } = useSessionDragDrop(); + const [showSessionMixer, setShowSessionMixer] = useState(false); const handleCloseColorMenu = useCallback(() => setColorMenu(null), []); @@ -321,6 +323,11 @@ export function SessionView() { })}
+ setShowSessionMixer((v) => !v)} + /> + {colorMenu && ( ({ + saveProject: vi.fn(), +})); + +vi.mock('../../../hooks/useAudioEngine', () => ({ + getAudioEngine: () => ({ + getTrackMeter: () => ({ leftLevel: 0, rightLevel: 0, clipped: false }), + getTrackLevel: () => 0, + resetTrackClip: vi.fn(), + masterVolume: 1, + getMasterLevel: () => ({ left: 0, right: 0 }), + getMasterInputLevel: () => ({ left: 0, right: 0 }), + getAnalyserData: () => null, + }), +})); + +function setupProjectWithTrack() { + useProjectStore.getState().createProject(); + useProjectStore.getState().addTrack('stems'); +} + +describe('SessionMixerStrip', () => { + it('renders volume fader for the given track', () => { + render( + + ); + + expect(screen.getByRole('slider', { name: /volume/i })).toBeInTheDocument(); + }); + + it('renders pan knob', () => { + render( + + ); + + expect(screen.getByLabelText(/pan knob/i)).toBeInTheDocument(); + }); + + it('renders solo and mute buttons', () => { + render( + + ); + + expect(screen.getByRole('button', { name: /solo/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /mute/i })).toBeInTheDocument(); + }); + + it('calls onVolumeChange when fader is interacted with', () => { + const onVolumeChange = vi.fn(); + + render( + + ); + + const fader = screen.getByRole('slider', { name: /volume/i }); + fireEvent.pointerDown(fader, { clientX: 50 }); + expect(onVolumeChange).toHaveBeenCalled(); + }); + + it('calls onMuteToggle when M button is clicked', () => { + const onMuteToggle = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /mute/i })); + expect(onMuteToggle).toHaveBeenCalled(); + }); + + it('calls onSoloToggle when S button is clicked', () => { + const onSoloToggle = vi.fn(); + + render( + + ); + + fireEvent.click(screen.getByRole('button', { name: /solo/i })); + expect(onSoloToggle).toHaveBeenCalled(); + }); + + it('shows active state on solo button when track is soloed', () => { + render( + + ); + + const soloBtn = screen.getByRole('button', { name: /solo/i }); + expect(soloBtn.className).toContain('bg-green'); + }); + + it('shows active state on mute button when track is muted', () => { + render( + + ); + + const muteBtn = screen.getByRole('button', { name: /mute/i }); + expect(muteBtn.className).toContain('bg-amber'); + }); + + it('shows track color accent on left edge', () => { + const { container } = render( + + ); + + const colorAccent = container.querySelector('[data-testid="track-color-accent"]'); + expect(colorAccent).toBeInTheDocument(); + // JSDOM converts hex colors to rgb() format + expect(colorAccent!.getAttribute('style')).toContain('background-color'); + }); +}); + +describe('SessionMixer', () => { + beforeEach(() => { + setupProjectWithTrack(); + }); + + it('renders a mixer strip for each track when visible', () => { + const project = useProjectStore.getState().project!; + + render( {}} />); + + for (const track of project.tracks) { + expect(screen.getByTestId(`session-mixer-strip-${track.id}`)).toBeInTheDocument(); + } + }); + + it('does not render strips when hidden', () => { + render( {}} />); + + const mixer = screen.getByTestId('session-mixer'); + // The inner container should have height 0 + const container = mixer.querySelector('.overflow-hidden'); + expect(container).toBeInTheDocument(); + expect((container as HTMLElement).style.height).toBe('0px'); + }); + + it('calls onToggle when toggle button is clicked', () => { + const onToggle = vi.fn(); + render(); + + fireEvent.click(screen.getByRole('button', { name: /mixer/i })); + expect(onToggle).toHaveBeenCalled(); + }); + + it('updates store when volume is changed on a strip', () => { + render( {}} />); + + const project = useProjectStore.getState().project!; + const track = project.tracks[0]; + const fader = screen.getAllByRole('slider', { name: /volume/i })[0]; + + // Simulate fader interaction + const rect = { left: 0, width: 100, top: 0, bottom: 14, height: 14, right: 100, x: 0, y: 0, toJSON: () => {} }; + vi.spyOn(fader, 'getBoundingClientRect').mockReturnValue(rect as DOMRect); + fireEvent.pointerDown(fader, { clientX: 50 }); + + const updatedTrack = useProjectStore.getState().project!.tracks.find(t => t.id === track.id)!; + expect(updatedTrack.volume).toBeCloseTo(0.5, 1); + }); + + it('updates store when mute is toggled', () => { + render( {}} />); + + const project = useProjectStore.getState().project!; + const track = project.tracks[0]; + const initialMuted = track.muted; + + const muteButtons = screen.getAllByRole('button', { name: /mute/i }); + fireEvent.click(muteButtons[0]); + + const updatedTrack = useProjectStore.getState().project!.tracks.find(t => t.id === track.id)!; + expect(updatedTrack.muted).toBe(!initialMuted); + }); + + it('updates store when solo is toggled', () => { + render( {}} />); + + const project = useProjectStore.getState().project!; + const track = project.tracks[0]; + const initialSoloed = track.soloed; + + const soloButtons = screen.getAllByRole('button', { name: /solo/i }); + fireEvent.click(soloButtons[0]); + + const updatedTrack = useProjectStore.getState().project!.tracks.find(t => t.id === track.id)!; + expect(updatedTrack.soloed).toBe(!initialSoloed); + }); +});