Skip to content

Commit c6755dc

Browse files
authored
Merge pull request #52 from boostcampwm-snu-2026-1/feat/43-mixer-audio-routing
feat: route arrangement playback through mixer
2 parents e01b6ab + b382f80 commit c6755dc

17 files changed

Lines changed: 725 additions & 155 deletions

docs/audio-engine.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,10 @@ Implementation names may differ. React components should not hold or mutate Web
135135

136136
Level meters are display feedback only. The UI may poll meter snapshots with `requestAnimationFrame` or subscribe through an audio-engine callback. Meter timing must not drive audio scheduling.
137137

138+
The current browser engine exposes meter snapshots as normalized runtime values from track and master `AnalyserNode` instances. These snapshots are display data only and are not serializable project state.
139+
140+
The first mixer routing path applies to track-aware `SONG` arrangement events. Preview and `PAT` playback may continue using the existing direct playback path unless a later feature explicitly adds clip-editor mixer routing.
141+
138142
## One-shot Sample Playback
139143

140144
Use a new `AudioBufferSourceNode` for every one-shot playback. A source node cannot be restarted after it has played.

docs/data-model.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ The first arrangement mixer panel should be a UI shell and may keep fader, mute,
5252

5353
Do not silently introduce persisted mixer semantics in a UI-only feature. When mixer routing is implemented, track and master mixer state should become serializable project data or serializable app-model data while runtime audio objects remain outside project JSON.
5454

55+
The first functional mixer implementation keeps track mixer settings as app-model state keyed by `trackId`, with a separate master mixer state. This remains serializable and can move into the future persisted `Project` shape without storing Web Audio nodes.
56+
5557
Recommended first mixer state:
5658

5759
```ts
@@ -69,6 +71,8 @@ export interface MasterMixerState {
6971

7072
Default values should be `volumeDb: 0`, `muted: false`, `solo: false`, and master `volumeDb: 0`.
7173

74+
The first fader range is `-60 dB` to `+6 dB`. `-60 dB` is treated as silent for practical gain calculation.
75+
7276
Use a simple, testable solo rule:
7377

7478
```text

docs/features/17-mixer-audio-routing-and-track-controls.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Feature: 17 Mixer Audio Routing and Track Controls
22

33
## Status
4-
Planned
4+
In Review
55

66
## Goal
77

docs/testing.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ CI uses `--if-present` while the repository is still before the Vite scaffold.
2929
- Tempo control and scheduler tempo update behavior: unit tests where practical.
3030
- Mixer decibel-to-gain conversion and mute/solo effective-gain logic: unit tests.
3131
- Mixer state transformations for volume, mute, solo, and master volume: unit tests.
32+
- Arrangement playback event expansion should preserve `trackId` so scheduled sources can route through the mixer.
3233
- Sustain loop point calculations: unit tests.
3334
- Sampler sustain metadata validation and fallback decisions: unit tests.
3435
- Scheduler calculations: unit tests where possible.

docs/ui-design.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ When playback is stopped, meters may settle to zero while faders and mute/solo s
194194

195195
Effect slots should remain visibly disabled or placeholder-only until a dedicated effects feature implements real processing.
196196

197+
The functional mixer should label its meters as live/runtime feedback and keep effect slots disabled. The master strip exposes master volume and meter feedback; track strips expose volume, mute, solo, and meter feedback.
198+
197199
## Component Naming Recommendations
198200

199201
Prefer names that match the product domain:

src/app/App.tsx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createAudioEngine,
66
expandClipInstancesForPlayback,
77
type BundledSampleMeta,
8+
type MixerLevelSnapshot,
89
type NoteLoopEvent,
910
type SampleLoopEvent,
1011
} from "../audio";
@@ -26,6 +27,8 @@ import {
2627
createClipInstance,
2728
createDefaultArrangementLoopRange,
2829
createDefaultArrangementTracks,
30+
createDefaultMasterMixerState,
31+
createDefaultTrackMixerStates,
2932
createImportedAudioClipDraft,
3033
createImportedAudioIds,
3134
createEmptyHybridClip,
@@ -43,8 +46,10 @@ import {
4346
renameClip,
4447
toggleDrumSubstep,
4548
validateImportedWavFile,
49+
updateMasterMixerState,
4650
updateDrumLaneSample,
4751
updateDrumStepSubdivision,
52+
updateTrackMixerState,
4853
type ArrangementLoopRange,
4954
type ArrangementTrack,
5055
type Clip,
@@ -53,9 +58,11 @@ import {
5358
type DrumLaneId,
5459
type DrumStepSubdivision,
5560
type HybridClip,
61+
type MasterMixerState,
5662
type NoteEvent,
5763
type PitchedInstrumentId,
5864
type SampleMeta,
65+
type TrackMixerState,
5966
} from "../model";
6067
import { DEFAULT_TEMPO_BPM, clampTempoBpm, type Tick } from "../utils";
6168
import styles from "./App.module.css";
@@ -87,6 +94,15 @@ function noteEventsToNoteLoopEvents(
8794
}));
8895
}
8996

97+
function createEmptyMixerLevels(
98+
tracks: readonly ArrangementTrack[],
99+
): MixerLevelSnapshot {
100+
return {
101+
masterLevel: 0,
102+
trackLevels: Object.fromEntries(tracks.map((track) => [track.id, 0])),
103+
};
104+
}
105+
90106
function createNextHybridClip(clips: readonly Clip[]): HybridClip {
91107
const nextClipNumber =
92108
clips.reduce((highestClipNumber, clip) => {
@@ -114,6 +130,17 @@ export function App() {
114130
const [arrangementTracks] = useState<ArrangementTrack[]>(() =>
115131
createDefaultArrangementTracks(),
116132
);
133+
const [trackMixerStates, setTrackMixerStates] = useState<TrackMixerState[]>(
134+
() => createDefaultTrackMixerStates(arrangementTracks),
135+
);
136+
const trackMixerStatesRef = useRef<TrackMixerState[]>(trackMixerStates);
137+
const [masterMixerState, setMasterMixerState] = useState<MasterMixerState>(() =>
138+
createDefaultMasterMixerState(),
139+
);
140+
const masterMixerStateRef = useRef<MasterMixerState>(masterMixerState);
141+
const [mixerLevels, setMixerLevels] = useState<MixerLevelSnapshot>(() =>
142+
createEmptyMixerLevels(arrangementTracks),
143+
);
117144
const [arrangementLoopRange, setArrangementLoopRange] =
118145
useState<ArrangementLoopRange>(() => createDefaultArrangementLoopRange());
119146
const arrangementLoopRangeRef =
@@ -175,6 +202,16 @@ export function App() {
175202
sampleMetasRef.current = sampleMetas;
176203
}, [sampleMetas]);
177204

205+
useEffect(() => {
206+
trackMixerStatesRef.current = trackMixerStates;
207+
audioEngine.setTrackMixerStates(trackMixerStates);
208+
}, [trackMixerStates]);
209+
210+
useEffect(() => {
211+
masterMixerStateRef.current = masterMixerState;
212+
audioEngine.setMasterMixerState(masterMixerState);
213+
}, [masterMixerState]);
214+
178215
useEffect(() => {
179216
return () => {
180217
audioEngine.stopCachedSamplePreview();
@@ -203,6 +240,26 @@ export function App() {
203240
};
204241
}, [transportState]);
205242

243+
useEffect(() => {
244+
if (transportMode !== "song" || transportState !== "playing") {
245+
return;
246+
}
247+
248+
let animationFrameId = 0;
249+
const trackIds = arrangementTracks.map((track) => track.id);
250+
251+
function updateMixerLevels() {
252+
setMixerLevels(audioEngine.getMixerLevels(trackIds));
253+
animationFrameId = window.requestAnimationFrame(updateMixerLevels);
254+
}
255+
256+
animationFrameId = window.requestAnimationFrame(updateMixerLevels);
257+
258+
return () => {
259+
window.cancelAnimationFrame(animationFrameId);
260+
};
261+
}, [arrangementTracks, transportMode, transportState]);
262+
206263
function commitSelectedClip(nextClip: HybridClip) {
207264
const nextClips = clipsRef.current.map((clip) =>
208265
clip.id === nextClip.id ? nextClip : clip,
@@ -298,6 +355,42 @@ export function App() {
298355
setArrangementLoopRange(normalizedLoopRange);
299356
}
300357

358+
function handleTrackVolumeChange(trackId: string, volumeDb: number) {
359+
setTrackMixerStates((currentStates) =>
360+
updateTrackMixerState(currentStates, trackId, { volumeDb }),
361+
);
362+
}
363+
364+
function handleTrackMuteToggle(trackId: string) {
365+
setTrackMixerStates((currentStates) => {
366+
const currentState = currentStates.find(
367+
(state) => state.trackId === trackId,
368+
);
369+
370+
return updateTrackMixerState(currentStates, trackId, {
371+
muted: !(currentState?.muted ?? false),
372+
});
373+
});
374+
}
375+
376+
function handleTrackSoloToggle(trackId: string) {
377+
setTrackMixerStates((currentStates) => {
378+
const currentState = currentStates.find(
379+
(state) => state.trackId === trackId,
380+
);
381+
382+
return updateTrackMixerState(currentStates, trackId, {
383+
solo: !(currentState?.solo ?? false),
384+
});
385+
});
386+
}
387+
388+
function handleMasterVolumeChange(volumeDb: number) {
389+
setMasterMixerState((currentState) =>
390+
updateMasterMixerState(currentState, { volumeDb }),
391+
);
392+
}
393+
301394
function getSelectedHybridClip(): HybridClip | null {
302395
const clip = selectedClipRef.current;
303396

@@ -860,6 +953,7 @@ export function App() {
860953
}
861954

862955
stopAudioClipPreview();
956+
setMixerLevels(createEmptyMixerLevels(arrangementTracks));
863957

864958
if (transportState !== "stopped") {
865959
const snapshot = audioEngine.stopLoop();
@@ -883,6 +977,7 @@ export function App() {
883977
} catch (error) {
884978
setTransportState("stopped");
885979
commitPlayheadTick(audioEngine.stopLoop().currentTick);
980+
setMixerLevels(createEmptyMixerLevels(arrangementTracks));
886981
setAudioError(
887982
error instanceof Error ? error.message : "Arrangement playback failed.",
888983
);
@@ -917,6 +1012,9 @@ export function App() {
9171012
clips: clipsRef.current,
9181013
});
9191014

1015+
audioEngine.setTrackMixerStates(trackMixerStatesRef.current);
1016+
audioEngine.setMasterMixerState(masterMixerStateRef.current);
1017+
9201018
return audioEngine.startClipLoop({
9211019
loopEndTick: normalizedLoopRange.endTick,
9221020
loopStartTick: normalizedLoopRange.startTick,
@@ -1017,6 +1115,7 @@ export function App() {
10171115
const snapshot = audioEngine.stopLoop();
10181116
setTransportState(snapshot.status);
10191117
commitPlayheadTick(snapshot.currentTick);
1118+
setMixerLevels(createEmptyMixerLevels(arrangementTracks));
10201119
return;
10211120
}
10221121

@@ -1025,6 +1124,7 @@ export function App() {
10251124
const snapshot = audioEngine.pauseLoop();
10261125
setTransportState(snapshot.status);
10271126
commitPlayheadTick(snapshot.currentTick);
1127+
setMixerLevels(createEmptyMixerLevels(arrangementTracks));
10281128
return;
10291129
}
10301130

@@ -1129,9 +1229,16 @@ export function App() {
11291229
onClipInstanceMove={handleClipInstanceMove}
11301230
onClipInstanceSelect={setSelectedClipInstanceId}
11311231
onLoopRangeChange={handleArrangementLoopRangeChange}
1232+
onMasterVolumeChange={handleMasterVolumeChange}
1233+
onTrackMuteToggle={handleTrackMuteToggle}
1234+
onTrackSoloToggle={handleTrackSoloToggle}
1235+
onTrackVolumeChange={handleTrackVolumeChange}
11321236
playheadTick={playheadTick}
1237+
masterMixerState={masterMixerState}
1238+
mixerLevels={mixerLevels}
11331239
selectedClipInstanceId={selectedClipInstanceId}
11341240
shouldShowPlayhead={shouldShowPlayhead}
1241+
trackMixerStates={trackMixerStates}
11351242
tracks={arrangementTracks}
11361243
/>
11371244
) : selectedAudioClip ? (

src/audio/arrangement-events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ export function expandClipInstancesForPlayback({
3838
id: `${instance.id}:audio`,
3939
sampleId: clip.sampleId,
4040
startTick: instance.startTick,
41+
trackId: instance.trackId,
4142
});
4243
}
4344

@@ -58,6 +59,7 @@ export function expandClipInstancesForPlayback({
5859
id: `${instance.id}:${event.id}`,
5960
sampleId: event.sampleId,
6061
startTick: instance.startTick + event.startTick,
62+
trackId: instance.trackId,
6163
});
6264
}
6365

@@ -73,6 +75,7 @@ export function expandClipInstancesForPlayback({
7375
instrumentId: event.instrumentId,
7476
midiNote: event.midiNote,
7577
startTick: instance.startTick + event.startTick,
78+
trackId: instance.trackId,
7679
});
7780
}
7881
}

0 commit comments

Comments
 (0)