Skip to content
Merged
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
20 changes: 16 additions & 4 deletions src/components/mixer/LevelMeter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const SCALE_RIGHT_W = 16;
export interface LevelMeterProps {
trackId?: string;
masterStage?: 'input' | 'output';
returnTrackId?: string;
stereo?: boolean;
showScale?: boolean;
}
Expand All @@ -32,7 +33,7 @@ function fillToTopPct(fill: number): number {
return pad + (1 - fill) * (100 - 2 * pad);
}

export function LevelMeter({ trackId, masterStage, stereo, showScale }: LevelMeterProps) {
export function LevelMeter({ trackId, masterStage, returnTrackId, stereo, showScale }: LevelMeterProps) {
const rafRef = useRef<number>(0);
const canvasRef = useRef<HTMLCanvasElement>(null);
const leftBar = useRef<BarState>({ level: 0, peakLevel: 0, peakHoldFrames: 0 });
Expand Down Expand Up @@ -96,6 +97,11 @@ export function LevelMeter({ trackId, masterStage, stereo, showScale }: LevelMet
leftLevel = meter.level;
rightLevel = meter.level;
clipped = meter.clipped;
} else if (returnTrackId) {
const meter = engine.getReturnTrackMeter(returnTrackId);
leftLevel = meter.level;
rightLevel = meter.level;
clipped = meter.clipped;
} else if (trackId) {
const meter = engine.getTrackMeter(trackId);
leftLevel = isStereo ? meter.leftLevel : meter.level;
Expand Down Expand Up @@ -162,19 +168,25 @@ export function LevelMeter({ trackId, masterStage, stereo, showScale }: LevelMet

rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, [trackId, masterStage, isStereo, updateBar]);
}, [trackId, masterStage, returnTrackId, isStereo, updateBar]);

const label = masterStage
? `Master ${masterStage} level meter`
: `Mixer level meter for ${trackId}`;
: returnTrackId
? `Return track level meter for ${returnTrackId}`
: `Mixer level meter for ${trackId}`;
const clipResetLabel = masterStage
? `Reset clip indicator for master ${masterStage}`
: `Reset clip indicator for ${trackId}`;
: returnTrackId
? `Reset clip indicator for return ${returnTrackId}`
: `Reset clip indicator for ${trackId}`;

const resetClip = () => {
const engine = getAudioEngine();
if (masterStage) {
engine.resetMasterClip(masterStage);
} else if (returnTrackId) {
engine.resetReturnTrackClip(returnTrackId);
} else if (trackId) {
engine.resetTrackClip(trackId);
}
Expand Down
78 changes: 72 additions & 6 deletions src/components/mixer/MixerPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ function ChannelStrip({ track, faderHeight, returnTracks }: ChannelStripProps) {
<div
key={rt.id}
data-testid={`send-slot-${i}`}
className="flex items-center gap-1.5"
className="flex items-center gap-1"
>
{rt ? (
<>
Expand All @@ -281,10 +281,9 @@ function ChannelStrip({ track, faderHeight, returnTracks }: ChannelStripProps) {
aria-label={`Toggle pre/post fader for send to ${rt.name}`}
disabled={isFrozen}
onClick={() => {
const sendIdx = sends.findIndex((s) => s.returnTrackId === rt.id);
if (sendIdx >= 0) {
setSendPrePost(track.id, sendIdx, isPre ? 'post' : 'pre');
}
if (!send) updateTrackSend(track.id, rt.id, amount || 0.5);
const idx = sends.findIndex((s) => s.returnTrackId === rt.id);
if (idx >= 0) setSendPrePost(track.id, idx, isPre ? 'post' : 'pre');
}}
>
{isPre ? 'PRE' : 'POST'}
Expand All @@ -297,7 +296,7 @@ function ChannelStrip({ track, faderHeight, returnTracks }: ChannelStripProps) {
value={amount}
onChange={(e) => updateTrackSend(track.id, rt.id, parseFloat(e.target.value))}
aria-label={`Send ${track.displayName} to ${rt.name}`}
className="w-14 h-3 accent-blue-500"
className="w-10 h-3 accent-blue-500"
disabled={isFrozen}
/>
<button
Expand Down Expand Up @@ -389,6 +388,65 @@ interface MasterStripProps {
faderHeight: number;
}

interface ReturnTrackStripProps {
returnTrack: ReturnTrack;
faderHeight: number;
}

function ReturnTrackStrip({ returnTrack, faderHeight }: ReturnTrackStripProps) {
const updateReturnTrack = useProjectStore((s) => s.updateReturnTrack);

return (
<div
data-testid={`return-strip-${returnTrack.id}`}
className="flex h-full min-h-0 w-[72px] shrink-0 flex-col items-center border-l border-[#333] bg-[#282828] px-1 py-2 gap-1"
>
{/* Return track label */}
<span className="text-[10px] font-semibold text-teal-400 uppercase tracking-wider truncate w-full text-center" title={returnTrack.name}>
{returnTrack.name}
</span>

{/* Pan knob */}
<Knob
value={returnTrack.pan}
min={-1}
max={1}
defaultValue={0}
onChange={(v) => updateReturnTrack(returnTrack.id, { pan: v })}
label="Pan"
size={28}
step={0.01}
/>

{/* Effects indicator */}
<div className="text-[8px] text-zinc-500">
{returnTrack.effects.length > 0
? <span className="text-teal-400">{returnTrack.effects.length} FX</span>
: <span>No FX</span>
}
</div>

{/* Volume fader + meter */}
<div className="flex-1 flex flex-col items-center justify-end min-h-0 gap-1" style={{ height: faderHeight }}>
<div className="relative flex justify-center gap-1" style={{ height: faderHeight - 24 }}>
<LevelMeter returnTrackId={returnTrack.id} />
<VerticalFader
value={returnTrack.volume}
min={0}
max={1}
defaultValue={1}
onChange={(v) => updateReturnTrack(returnTrack.id, { volume: v })}
aria-label={`${returnTrack.name} volume fader`}
accentColor="#2dd4bf"
width={12}
/>
</div>
<span className="text-[10px] font-mono text-zinc-400">{volumeToDb(returnTrack.volume)}</span>
</div>
</div>
);
}

function MasterStrip({ faderHeight }: MasterStripProps) {
const project = useProjectStore((s) => s.project);
const updateProject = useProjectStore((s) => s.updateProject);
Expand Down Expand Up @@ -534,6 +592,14 @@ export function MixerPanel() {
{[...project.tracks].sort((a, b) => a.order - b.order).map((track) => (
<ChannelStrip key={track.id} track={track} faderHeight={faderHeight} returnTracks={returnTracks} />
))}
{returnTracks.length > 0 && (
<>
<div className="w-px self-stretch bg-teal-700/40" />
{returnTracks.map((rt) => (
<ReturnTrackStrip key={rt.id} returnTrack={rt} faderHeight={faderHeight} />
))}
</>
)}
<MasterStrip faderHeight={faderHeight} />
</div>
</div>
Expand Down
96 changes: 96 additions & 0 deletions src/engine/AudioEngine.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import * as Tone from 'tone';
import { TrackNode } from './TrackNode';
import { ReturnTrackNode } from './ReturnTrackNode';
import type {
AudioWarpMarker,
GainEnvelopePoint,
MasteringState,
ReturnTrack,
Send,
SequencerPattern,
TempoEvent,
TimeSignatureEvent,
Track,
} from '../types/project';
import { ensureMasteringState } from '../utils/mastering';
import { applyClipFadeAutomation } from '../utils/clipFade';
Expand Down Expand Up @@ -84,6 +88,7 @@ export class AudioEngine {
ctx: AudioContext;
masterGain: GainNode;
trackNodes: Map<string, TrackNode> = new Map();
returnTrackNodes: Map<string, ReturnTrackNode> = new Map();
scheduledSources: ScheduledSource[] = [];
private readonly masterInputGain: GainNode;
private readonly masterDryGain: GainNode;
Expand Down Expand Up @@ -1127,13 +1132,104 @@ export class AudioEngine {
}
}

// -----------------------------------------------------------------------
// Return Track Nodes & Send Routing
// -----------------------------------------------------------------------

getOrCreateReturnTrackNode(returnTrackId: string): ReturnTrackNode {
let node = this.returnTrackNodes.get(returnTrackId);
if (!node) {
node = new ReturnTrackNode(this.ctx, this.masterInputGain);
this.returnTrackNodes.set(returnTrackId, node);
}
return node;
}

removeReturnTrackNode(returnTrackId: string) {
const node = this.returnTrackNodes.get(returnTrackId);
if (node) {
node.disconnect();
this.returnTrackNodes.delete(returnTrackId);
}
}

getReturnTrackMeter(returnTrackId: string): { level: number; leftLevel: number; rightLevel: number; clipped: boolean } {
return this.returnTrackNodes.get(returnTrackId)?.getMeter() ?? { level: 0, leftLevel: 0, rightLevel: 0, clipped: false };
}

resetReturnTrackClip(returnTrackId: string) {
this.returnTrackNodes.get(returnTrackId)?.resetClip();
}

/**
* Synchronize send routing between tracks and return tracks.
* Creates/updates ReturnTrackNodes, wires send gain nodes, and cleans up stale connections.
*/
syncSends(tracks: Track[], returnTracks: ReturnTrack[]) {
const returnTrackIds = new Set(returnTracks.map(rt => rt.id));

// 1. Create/update ReturnTrackNodes
for (const rt of returnTracks) {
const node = this.getOrCreateReturnTrackNode(rt.id);
node.volume = rt.volume;
node.pan = rt.pan;
}

// 2. Remove ReturnTrackNodes that no longer exist in the data model
for (const [id] of this.returnTrackNodes) {
if (!returnTrackIds.has(id)) {
this.removeReturnTrackNode(id);
}
}

// 3. Wire sends for each track
const activeSends = new Map<string, Set<string>>();

for (const track of tracks) {
const trackNode = this.trackNodes.get(track.id);
if (!trackNode) continue;

const sends = track.sends ?? [];
const activeReturnIds = new Set<string>();

for (const send of sends) {
if (!returnTrackIds.has(send.returnTrackId)) continue;
if (send.amount <= 0) continue;

const returnNode = this.returnTrackNodes.get(send.returnTrackId);
if (!returnNode) continue;

activeReturnIds.add(send.returnTrackId);
trackNode.connectSend(send.returnTrackId, returnNode.inputGain, send.amount, (send.prePost ?? 'post') === 'pre');
}

Comment on lines +1188 to +1205
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.

syncSends() calls trackNode.connectSend(...) on every sync pass, and connectSend() always disconnects and recreates the GainNodes. Since useTransport calls syncSends during live parameter updates, this can cause unnecessary audio-graph churn (and potentially clicks because initial gain values are set immediately, not ramped). Prefer creating the send once and then calling trackNode.updateSendAmount(...) when only amount/preFader changes, and only recreate connections when the destination return node changes.

Copilot uses AI. Check for mistakes.
activeSends.set(track.id, activeReturnIds);
}

// 4. Disconnect sends that are no longer active
for (const track of tracks) {
const trackNode = this.trackNodes.get(track.id);
if (!trackNode) continue;
const active = activeSends.get(track.id) ?? new Set();
for (const send of (track.sends ?? [])) {
if (send.amount <= 0 && !active.has(send.returnTrackId)) {
trackNode.disconnectSend(send.returnTrackId);
}
}
}
Comment on lines +1185 to +1219
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.

syncSends() does not reliably disconnect sends that were removed from a track. When a send is deleted from track.sends (e.g. amount set to 0 and the entry is spliced out), the removed returnTrackId is no longer iterated, so TrackNode.disconnectSend() is never called and the old send remains wired. This can leave stale connections/memory and can keep feeding a return until the engine is rebuilt. Consider reconciling against the TrackNode’s currently-connected send IDs (expose them), tracking previous state in AudioEngine, or using trackNode.disconnectAllSends() and then re-adding the active ones each sync.

Copilot uses AI. Check for mistakes.
}

dispose() {
this.stop();
this.disposeAudioStream();
for (const node of this.trackNodes.values()) {
node.disconnect();
}
this.trackNodes.clear();
for (const node of this.returnTrackNodes.values()) {
node.disconnect();
}
this.returnTrackNodes.clear();
this.ctx.close();
}
}
2 changes: 1 addition & 1 deletion src/engine/AutomationEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export class AutomationEngine {
const engine = getAudioEngine();
const trackNode = engine.trackNodes.get(trackId);
if (trackNode) {
trackNode.updateSendAmount(send.returnTrackId, normalized);
trackNode.updateSendAmount(send.returnTrackId, normalized, (send.prePost ?? 'post') === 'pre');
}
}
}
Expand Down
Loading
Loading