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);
+ });
+});