Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/components/session/SessionMixer.tsx
Original file line number Diff line number Diff line change
@@ -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 });
}, []);
Comment on lines +18 to +21
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handlePanChange reaches into the store via useProjectStore.getState() and uses an empty dependency array. For consistency with the rest of the codebase (and to avoid bypassing React hooks patterns), select updateTrackMixer via useProjectStore((s) => s.updateTrackMixer) and include it in the callback dependencies.

Copilot uses AI. Check for mistakes.

const handleMuteToggle = useCallback((trackId: string, currentMuted: boolean) => {
updateTrack(trackId, { muted: !currentMuted });
}, [updateTrack]);

const handleSoloToggle = useCallback((trackId: string, currentSoloed: boolean) => {
updateTrack(trackId, { soloed: !currentSoloed });
}, [updateTrack]);
Comment on lines +23 to +29
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mute/solo handling here does not account for group tracks. Elsewhere in the UI (e.g. MixerPanel/TrackHeader) group tracks use setGroupMuted / setGroupSoloed so the action cascades to child tracks. With the current implementation, muting/soloing a group from the Session mixer will behave differently than other mixers.

Copilot uses AI. Check for mistakes.

if (!project) return null;

const tracks = [...project.tracks].sort((a, b) => a.order - b.order);

return (
<div data-testid="session-mixer">
{/* Toggle button */}
<div className="flex items-center justify-center border-t border-[#333]">
<button
onClick={onToggle}
className="w-full py-1 text-[10px] uppercase tracking-[0.16em] text-zinc-500 hover:text-zinc-300 hover:bg-[#252525] transition-colors"
aria-label={visible ? 'Hide session mixer' : 'Show session mixer'}
title={visible ? 'Hide Mixer' : 'Show Mixer'}
>
{visible ? 'Hide Mixer' : 'Show Mixer'}
</button>
</div>

{/* Mixer strips container */}
<div
className="overflow-hidden transition-[height,opacity] duration-150 ease-out"
style={{
height: visible ? `${tracks.length * 40}px` : '0px',
opacity: visible ? 1 : 0,
}}
>
{tracks.map((track) => (
<SessionMixerStrip
key={track.id}
trackId={track.id}
trackName={track.displayName}
trackColor={track.color}
volume={track.volume}
pan={track.pan ?? 0}
muted={track.muted}
soloed={track.soloed}
onVolumeChange={(v) => handleVolumeChange(track.id, v)}
onPanChange={(v) => handlePanChange(track.id, v)}
onMuteToggle={() => handleMuteToggle(track.id, track.muted)}
onSoloToggle={() => handleSoloToggle(track.id, track.soloed)}
/>
))}
Comment on lines +57 to +72
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When visible is false, the mixer still renders all SessionMixerStrip children inside a 0px-tall container. Each strip starts its own requestAnimationFrame loop for meters, so the mixer continues doing per-frame work even while hidden. Consider conditionally rendering the strips only when visible is true (or passing visible down to pause metering) to avoid unnecessary CPU usage when the panel is collapsed.

Suggested change
{tracks.map((track) => (
<SessionMixerStrip
key={track.id}
trackId={track.id}
trackName={track.displayName}
trackColor={track.color}
volume={track.volume}
pan={track.pan ?? 0}
muted={track.muted}
soloed={track.soloed}
onVolumeChange={(v) => handleVolumeChange(track.id, v)}
onPanChange={(v) => handlePanChange(track.id, v)}
onMuteToggle={() => handleMuteToggle(track.id, track.muted)}
onSoloToggle={() => handleSoloToggle(track.id, track.soloed)}
/>
))}
{visible &&
tracks.map((track) => (
<SessionMixerStrip
key={track.id}
trackId={track.id}
trackName={track.displayName}
trackColor={track.color}
volume={track.volume}
pan={track.pan ?? 0}
muted={track.muted}
soloed={track.soloed}
onVolumeChange={(v) => handleVolumeChange(track.id, v)}
onPanChange={(v) => handlePanChange(track.id, v)}
onMuteToggle={() => handleMuteToggle(track.id, track.muted)}
onSoloToggle={() => handleSoloToggle(track.id, track.soloed)}
/>
))}

Copilot uses AI. Check for mistakes.
</div>
</div>
);
}
196 changes: 196 additions & 0 deletions src/components/session/SessionMixerStrip.tsx
Original file line number Diff line number Diff line change
@@ -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<number>(0);
const [leftFill, setLeftFill] = useState(0);
const [rightFill, setRightFill] = useState(0);
const containerRef = useRef<HTMLDivElement>(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 (
<div
className="flex items-center gap-2 h-10 px-2 bg-[#1b1b1b] border-b border-[#2e2e2e]"
data-testid={`session-mixer-strip-${trackId}`}
>
{/* Track color accent */}
<div
className="w-1 h-6 rounded-sm shrink-0"
style={{ backgroundColor: trackColor }}
data-testid="track-color-accent"
/>

{/* Track name (truncated) */}
<div className="w-16 truncate text-[10px] text-zinc-400 shrink-0" title={trackName}>
{trackName}
</div>

{/* Volume fader with meter */}
<div
ref={containerRef}
className="relative flex-1 min-w-[80px] max-w-[160px] cursor-ew-resize select-none"
style={{ height: '14px' }}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onDoubleClick={onDoubleClick}
title={`Volume: ${Math.round(volume * 100)}%`}
aria-label={`${trackName} volume`}
role="slider"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(volume * 100)}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This slider is implemented as a non-focusable div with role="slider". For accessibility, ARIA sliders should be keyboard operable and focusable (e.g. tabIndex=0 plus arrow/home/end key handling), otherwise screen-reader users can discover it but not change the value without a pointer.

Suggested change
aria-valuenow={Math.round(volume * 100)}
aria-valuenow={Math.round(volume * 100)}
aria-orientation="horizontal"
tabIndex={0}
onKeyDown={(e) => {
const step = 0.05;
const largeStep = 0.1;
let newVolume: number | null = null;
switch (e.key) {
case 'ArrowLeft':
case 'ArrowDown':
newVolume = Math.max(0, volume - step);
break;
case 'ArrowRight':
case 'ArrowUp':
newVolume = Math.min(1, volume + step);
break;
case 'PageDown':
newVolume = Math.max(0, volume - largeStep);
break;
case 'PageUp':
newVolume = Math.min(1, volume + largeStep);
break;
case 'Home':
newVolume = 0;
break;
case 'End':
newVolume = 1;
break;
default:
break;
}
if (newVolume !== null) {
e.preventDefault();
// Prefer prop callback if available; otherwise fall back to direct state update if present.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore onVolumeChange is expected to be provided via props.
onVolumeChange?.(newVolume);
}
}}

Copilot uses AI. Check for mistakes.
>
{/* Meter bars */}
<div className="absolute inset-x-0 top-1/2 -translate-y-1/2 flex flex-col gap-[1px]">
<div className="h-[4px] rounded-[2px] bg-zinc-800/50 overflow-hidden">
<div
className="h-full rounded-[2px]"
style={{
width: `${leftFill * 100}%`,
background: 'linear-gradient(to right, #22c55e 0%, #84cc16 35%, #eab308 65%, #ef4444 95%)',
opacity: 0.7,
}}
/>
</div>
<div className="h-[4px] rounded-[2px] bg-zinc-800/50 overflow-hidden">
<div
className="h-full rounded-[2px]"
style={{
width: `${rightFill * 100}%`,
background: 'linear-gradient(to right, #22c55e 0%, #84cc16 35%, #eab308 65%, #ef4444 95%)',
opacity: 0.7,
}}
/>
</div>
</div>

{/* Fader position indicator */}
<div
className="absolute top-0 h-full w-[2px] bg-zinc-300 pointer-events-none"
style={{ left: `${faderPct}%`, transform: 'translateX(-50%)' }}
/>
</div>

{/* Pan knob */}
<div className="shrink-0">
<Knob
value={pan}
min={-1}
max={1}
defaultValue={0}
onChange={(v) => onPanChange?.(v)}
label="Pan"
size={24}
step={0.01}
/>
</div>

{/* Solo button */}
<button
onClick={onSoloToggle}
className={`w-6 h-6 rounded text-[10px] font-bold shrink-0 transition-colors ${
soloed
? 'bg-green-500 text-white'
: 'bg-[#343434] text-zinc-400 hover:bg-[#404040]'
}`}
aria-label={`Solo ${trackName}`}
title={soloed ? 'Unsolo' : 'Solo'}
>
S
</button>

{/* Mute button */}
<button
onClick={onMuteToggle}
className={`w-6 h-6 rounded text-[10px] font-bold shrink-0 transition-colors ${
muted
? 'bg-amber-500 text-white'
: 'bg-[#343434] text-zinc-400 hover:bg-[#404040]'
}`}
aria-label={`Mute ${trackName}`}
title={muted ? 'Unmute' : 'Mute'}
>
M
</button>
</div>
);
}
7 changes: 7 additions & 0 deletions src/components/session/SessionView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getSessionSlotProgress } from '../../utils/sessionProgress';
import { getSessionClips } from '../../utils/sessionClips';
import { ContextMenuWrapper, ContextMenuSeparator, ContextMenuItem } from '../ui/ContextMenu';
import { ColorSwatchPalette } from '../ui/ColorSwatchPalette';
import { SessionMixer } from './SessionMixer';
import type { Clip, Track, SessionLaunchQuantization, SessionClipSlot, SessionPendingLaunch, SessionScene } from '../../types/project';

const SESSION_QUANTIZATION_OPTIONS: SessionLaunchQuantization[] = [
Expand Down Expand Up @@ -69,6 +70,7 @@ export function SessionView() {
} = useTransport();

const [colorMenu, setColorMenu] = useState<SlotColorMenuState | null>(null);
const [showSessionMixer, setShowSessionMixer] = useState(false);

const handleCloseColorMenu = useCallback(() => setColorMenu(null), []);

Expand Down Expand Up @@ -216,6 +218,11 @@ export function SessionView() {
})}
</div>

<SessionMixer
visible={showSessionMixer}
onToggle={() => setShowSessionMixer((v) => !v)}
/>

{colorMenu && (
<ContextMenuWrapper
x={colorMenu.x}
Expand Down
Loading
Loading