Skip to content

Commit e88fdc9

Browse files
committed
feat(editor): Audio Mixer panel - the project mix is a bottom-dock tab
One strip per bus (the default five plus project customs): volume fader, mute, insert-chain editor (filter/reverb/compressor with inline params), a duck-by rule, and add/remove custom buses. Edits persist to project.esproject features.audio through ProjectStore.setAudio and live-apply to the edit realm's Audio resource, so tweaks are audible immediately and Play/exports boot the identical mix. Also fixes the CSP hole the live check surfaced: media-src never allowed estella://, so every <audio> element - including the existing double-click preview - was silently blocked.
1 parent 90a057b commit e88fdc9

7 files changed

Lines changed: 350 additions & 1 deletion

File tree

desktop/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="UTF-8" />
55
<meta
66
http-equiv="Content-Security-Policy"
7-
content="default-src 'self'; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: estella:; font-src 'self' data:; connect-src 'self' ws: estella:; worker-src 'self' blob:; frame-src 'self' estella:;"
7+
content="default-src 'self'; script-src 'self' 'unsafe-eval' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: estella:; media-src 'self' estella:; font-src 'self' data:; connect-src 'self' ws: estella:; worker-src 'self' blob:; frame-src 'self' estella:;"
88
/>
99
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
1010
<title>Estella Editor</title>

desktop/src/i18n/messages/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { sequencerMessages } from './sequencer';
3333
import { nodegraphMessages } from './nodegraph';
3434
import { tileMessages } from './tile';
3535
import { flipbookMessages } from './flipbook';
36+
import { mixerMessages } from './mixer';
3637
import { materialMessages } from './material';
3738
import { projectMessages } from './project';
3839

@@ -53,6 +54,7 @@ export const messageModules = {
5354
nodegraph: nodegraphMessages,
5455
tile: tileMessages,
5556
flipbook: flipbookMessages,
57+
mixer: mixerMessages,
5658
material: materialMessages,
5759
project: projectMessages,
5860
} as const;
@@ -73,6 +75,7 @@ export const editorMessages = {
7375
...nodegraphMessages,
7476
...tileMessages,
7577
...flipbookMessages,
78+
...mixerMessages,
7679
...materialMessages,
7780
...projectMessages,
7881
} as const;

desktop/src/i18n/messages/mixer.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team
3+
/**
4+
* @file mixer.ts — Audio Mixer panel: bus strips, effects, ducking.
5+
*/
6+
import { defineMessages } from './types';
7+
8+
export const mixerMessages = defineMessages({
9+
'mix.panelTitle': { en: 'Audio Mixer', zh: '音频混音器' },
10+
'mix.noProject': { en: 'Open a project to edit its mixer.', zh: '打开项目后编辑其混音器。' },
11+
'mix.mute': { en: 'Mute', zh: '静音' },
12+
'mix.addBus': { en: 'Add Bus', zh: '添加总线' },
13+
'mix.removeBus': { en: 'Remove bus', zh: '移除总线' },
14+
'mix.duckBy': { en: 'Duck by', zh: '被压低于' },
15+
'mix.duckNone': { en: '(none)', zh: '(无)' },
16+
'mix.duckAmount': { en: 'Duck level while the trigger bus is active', zh: '触发总线活跃时压低到的音量' },
17+
18+
'mix.fx.add': { en: '+ Effect', zh: '+ 效果' },
19+
'mix.fx.filter': { en: 'Filter', zh: '滤波器' },
20+
'mix.fx.filterKind': { en: 'Filter type', zh: '滤波类型' },
21+
'mix.fx.reverb': { en: 'Reverb', zh: '混响' },
22+
'mix.fx.compressor': { en: 'Compressor', zh: '压缩器' },
23+
'mix.fx.seconds': { en: 'Tail s', zh: '尾音秒' },
24+
'mix.fx.wet': { en: 'Wet', zh: '湿声' },
25+
'mix.fx.ratio': { en: 'Ratio', zh: '压缩比' },
26+
'mix.fx.remove': { en: 'Remove effect', zh: '移除效果' },
27+
});

desktop/src/layout/ActivityBar.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@ export function ActivityBar() {
8686
>
8787
<Gauge size={19} strokeWidth={1.7} />
8888
</button>
89+
<button
90+
type="button"
91+
className="act"
92+
title={t('mix.panelTitle')}
93+
onClick={() => dockApi.revealAndExpand('audiomixer')}
94+
>
95+
<SlidersHorizontal size={19} strokeWidth={1.7} />
96+
</button>
8997

9098
<span className="act-spacer" />
9199

desktop/src/layout/DockLayout.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { GamePanel, GameClientPanel } from '@/panels/GamePanel';
2020
import { Sequencer } from '@/panels/Sequencer';
2121
import { TilesetEditor } from '@/panels/TilesetEditor';
2222
import { FlipbookEditor } from '@/panels/FlipbookEditor';
23+
import { AudioMixerPanel } from '@/panels/AudioMixerPanel';
2324
import { TilemapPainter } from '@/panels/TilemapPainter';
2425
import { UIWidgetsPanel } from '@/panels/UIWidgetsPanel';
2526
import { MaterialGraphEditor } from '@/panels/MaterialGraphEditor';
@@ -47,6 +48,7 @@ const components: Record<string, FC<IDockviewPanelProps>> = {
4748
sequencer: () => panel('sequencer', <Sequencer />),
4849
tileset: () => panel('tileset', <TilesetEditor />),
4950
flipbook: () => panel('flipbook', <FlipbookEditor />),
51+
audiomixer: () => panel('audiomixer', <AudioMixerPanel />),
5052
tilemap: () => panel('tilemap', <TilemapPainter />),
5153
uiWidgets: () => panel('uiWidgets', <UIWidgetsPanel />),
5254
materialgraph: () => panel('materialgraph', <MaterialGraphEditor />),
@@ -114,6 +116,7 @@ function buildDefaultLayout(api: DockviewReadyEvent['api']) {
114116
const BOTTOM_TABS: { id: string; component: string; title: string; refs: string[] }[] = [
115117
{ id: 'sequencer', component: 'sequencer', title: t('layout.panel.sequencer'), refs: ['content', 'log'] },
116118
{ id: 'profiler', component: 'profiler', title: t('layout.panel.profiler'), refs: ['log', 'content'] },
119+
{ id: 'audiomixer', component: 'audiomixer', title: t('mix.panelTitle'), refs: ['log', 'content'] },
117120
];
118121

119122
function ensureBottomTabs(api: DockviewReadyEvent['api']) {
@@ -145,6 +148,7 @@ const PANEL_TITLES: Record<string, () => string> = {
145148
game: () => t('layout.panel.game'),
146149
tileset: () => t('tile.panelTileset'),
147150
flipbook: () => t('fb.panelTitle'),
151+
audiomixer: () => t('mix.panelTitle'),
148152
tilemap: () => t('panel.tilemap'),
149153
'ui-widgets': () => t('panel.uiWidgets'),
150154
materialgraph: () => t('mat.panelTitle'),
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright (c) 2024-present ESEngine Team
3+
/**
4+
* @file AudioMixerPanel.tsx
5+
* @brief The project mixer — one strip per bus (volume fader, mute, insert
6+
* chain, duck rule) editing `project.esproject` features.audio. Every
7+
* edit persists through ProjectStore.setAudio AND live-applies to the
8+
* edit realm's Audio resource, so tweaks are audible immediately and
9+
* Play/exports boot the same mix (the config rides the physics-config
10+
* pipeline).
11+
*/
12+
13+
import { useSyncExternalStore } from 'react';
14+
import { Plus, Trash2, VolumeX, Volume2 } from 'lucide-react';
15+
import {
16+
Audio, applyAudioProjectConfig,
17+
type AudioProjectConfig, type AudioBusDecl, type BusEffectDef,
18+
} from 'esengine';
19+
import { ProjectStore } from '@/project/ProjectStore';
20+
import { EngineHost } from '@/engine/EngineHost';
21+
import { Select } from '@/components/Select';
22+
import { t } from '@/i18n';
23+
24+
/** The always-present mixer tree; custom buses append after these. */
25+
const DEFAULT_BUSES = ['master', 'music', 'sfx', 'ui', 'voice'];
26+
const DEFAULT_VOLUME: Record<string, number> = { master: 1, music: 0.8, sfx: 1, ui: 1, voice: 1 };
27+
28+
interface StripModel extends AudioBusDecl {
29+
builtin: boolean;
30+
}
31+
32+
/** Default buses merged with the project declarations, declaration order kept. */
33+
function stripsOf(config: AudioProjectConfig): StripModel[] {
34+
const byName = new Map((config.buses ?? []).map((b) => [b.name, b]));
35+
const strips: StripModel[] = DEFAULT_BUSES.map((name) => ({
36+
name, volume: DEFAULT_VOLUME[name], ...byName.get(name), builtin: true,
37+
}));
38+
for (const b of config.buses ?? []) {
39+
if (!DEFAULT_BUSES.includes(b.name)) strips.push({ ...b, builtin: false });
40+
}
41+
return strips;
42+
}
43+
44+
/** Persist + live-apply one new config state. */
45+
function commit(next: AudioProjectConfig): void {
46+
void ProjectStore.setAudio(next);
47+
const audio = EngineHost.getResource(Audio);
48+
if (audio) applyAudioProjectConfig(audio, next);
49+
}
50+
51+
/** Replace/patch one bus's declaration inside the config. */
52+
function patchBus(config: AudioProjectConfig, name: string, patch: Partial<AudioBusDecl> | null): AudioProjectConfig {
53+
const buses = [...(config.buses ?? [])];
54+
const i = buses.findIndex((b) => b.name === name);
55+
if (patch === null) {
56+
if (i >= 0) buses.splice(i, 1);
57+
} else if (i >= 0) {
58+
buses[i] = { ...buses[i], ...patch };
59+
} else {
60+
buses.push({ name, ...patch });
61+
}
62+
return { buses };
63+
}
64+
65+
const FX_KINDS: Array<{ value: BusEffectDef['type']; label: () => string }> = [
66+
{ value: 'filter', label: () => t('mix.fx.filter') },
67+
{ value: 'reverb', label: () => t('mix.fx.reverb') },
68+
{ value: 'compressor', label: () => t('mix.fx.compressor') },
69+
];
70+
71+
function defaultEffect(type: BusEffectDef['type']): BusEffectDef {
72+
switch (type) {
73+
case 'filter': return { type: 'filter', filter: 'lowpass', frequency: 1200, q: 1 };
74+
case 'reverb': return { type: 'reverb', seconds: 1.5, wet: 0.35 };
75+
case 'compressor': return { type: 'compressor', thresholdDb: -24, ratio: 4 };
76+
}
77+
}
78+
79+
function NumCell(props: { label: string; value: number; step?: number; onCommit: (n: number) => void }) {
80+
return (
81+
<label className="mix-num">
82+
<span>{props.label}</span>
83+
<input
84+
type="number" defaultValue={props.value} key={props.value} step={props.step ?? 1}
85+
onBlur={(e) => {
86+
const n = Number(e.target.value);
87+
if (Number.isFinite(n) && n !== props.value) props.onCommit(n);
88+
}}
89+
onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); }}
90+
/>
91+
</label>
92+
);
93+
}
94+
95+
function EffectRow(props: { fx: BusEffectDef; onChange: (fx: BusEffectDef) => void; onRemove: () => void }) {
96+
const { fx, onChange, onRemove } = props;
97+
return (
98+
<div className="mix-fx">
99+
<span className="mix-fx-kind">{FX_KINDS.find((k) => k.value === fx.type)?.label()}</span>
100+
{fx.type === 'filter' && (
101+
<>
102+
<Select
103+
ariaLabel={t('mix.fx.filterKind')}
104+
value={fx.filter}
105+
options={(['lowpass', 'highpass', 'bandpass', 'lowshelf', 'highshelf', 'peaking', 'notch'] as const)
106+
.map((v) => ({ value: v, label: v }))}
107+
onChange={(v) => onChange({ ...fx, filter: v })}
108+
/>
109+
<NumCell label="Hz" value={fx.frequency} step={50} onCommit={(n) => onChange({ ...fx, frequency: Math.max(10, n) })} />
110+
<NumCell label="Q" value={fx.q ?? 1} step={0.1} onCommit={(n) => onChange({ ...fx, q: n })} />
111+
</>
112+
)}
113+
{fx.type === 'reverb' && (
114+
<>
115+
<NumCell label={t('mix.fx.seconds')} value={fx.seconds ?? 1.5} step={0.1} onCommit={(n) => onChange({ ...fx, seconds: Math.max(0.05, n) })} />
116+
<NumCell label={t('mix.fx.wet')} value={fx.wet ?? 0.35} step={0.05} onCommit={(n) => onChange({ ...fx, wet: Math.max(0, Math.min(1, n)) })} />
117+
</>
118+
)}
119+
{fx.type === 'compressor' && (
120+
<>
121+
<NumCell label="dB" value={fx.thresholdDb ?? -24} onCommit={(n) => onChange({ ...fx, thresholdDb: n })} />
122+
<NumCell label={t('mix.fx.ratio')} value={fx.ratio ?? 4} step={0.5} onCommit={(n) => onChange({ ...fx, ratio: Math.max(1, n) })} />
123+
</>
124+
)}
125+
<button type="button" className="mix-rm" title={t('mix.fx.remove')} onClick={onRemove}>
126+
<Trash2 size={12} />
127+
</button>
128+
</div>
129+
);
130+
}
131+
132+
function BusStrip(props: { strip: StripModel; all: StripModel[]; config: AudioProjectConfig }) {
133+
const { strip, all, config } = props;
134+
const volume = strip.volume ?? 1;
135+
const patch = (p: Partial<AudioBusDecl> | null) => commit(patchBus(config, strip.name, p));
136+
const effects = strip.effects ?? [];
137+
const duckTargets = all.filter((s) => s.name !== strip.name).map((s) => s.name);
138+
139+
return (
140+
<div className="mix-strip">
141+
<div className="mix-head">
142+
<span className="mix-name">{strip.name}</span>
143+
{!strip.builtin && (
144+
<button type="button" className="mix-rm" title={t('mix.removeBus')} onClick={() => patch(null)}>
145+
<Trash2 size={12} />
146+
</button>
147+
)}
148+
</div>
149+
150+
<div className="mix-vol">
151+
<input
152+
type="range" min={0} max={1} step={0.01} value={volume}
153+
className="mix-fader"
154+
onChange={(e) => patch({ volume: Number(e.target.value) })}
155+
/>
156+
<span className="mix-vol-num">{Math.round(volume * 100)}</span>
157+
<button
158+
type="button"
159+
className={'mix-mute' + (strip.muted ? ' is-on' : '')}
160+
title={t('mix.mute')}
161+
onClick={() => patch({ muted: !strip.muted })}
162+
>
163+
{strip.muted ? <VolumeX size={13} /> : <Volume2 size={13} />}
164+
</button>
165+
</div>
166+
167+
<div className="mix-fxs">
168+
{effects.map((fx, i) => (
169+
<EffectRow
170+
key={`${i}-${fx.type}`}
171+
fx={fx}
172+
onChange={(nf) => patch({ effects: effects.map((e, j) => (j === i ? nf : e)) })}
173+
onRemove={() => patch({ effects: effects.filter((_, j) => j !== i) })}
174+
/>
175+
))}
176+
<Select
177+
ariaLabel={t('mix.fx.add')}
178+
value={'' as string}
179+
options={[
180+
{ value: '', label: t('mix.fx.add') },
181+
...FX_KINDS.map((k) => ({ value: k.value as string, label: k.label() })),
182+
]}
183+
onChange={(v) => { if (v) patch({ effects: [...effects, defaultEffect(v as BusEffectDef['type'])] }); }}
184+
/>
185+
</div>
186+
187+
{strip.name !== 'master' && (
188+
<div className="mix-duck">
189+
<span className="mix-lbl">{t('mix.duckBy')}</span>
190+
<Select
191+
ariaLabel={t('mix.duckBy')}
192+
value={strip.duck?.trigger ?? ''}
193+
options={[{ value: '', label: t('mix.duckNone') }, ...duckTargets.map((n) => ({ value: n, label: n }))]}
194+
onChange={(v) => patch({ duck: v ? { trigger: v, amount: strip.duck?.amount ?? 0.3 } : undefined })}
195+
/>
196+
{strip.duck && (
197+
<input
198+
type="range" min={0} max={1} step={0.05} value={strip.duck.amount}
199+
className="mix-duck-amt" title={t('mix.duckAmount')}
200+
onChange={(e) => patch({ duck: { ...strip.duck!, amount: Number(e.target.value) } })}
201+
/>
202+
)}
203+
</div>
204+
)}
205+
</div>
206+
);
207+
}
208+
209+
export function AudioMixerPanel() {
210+
const project = useSyncExternalStore(ProjectStore.subscribe, ProjectStore.getSnapshot);
211+
if (!project) {
212+
return <div className="mix-empty">{t('mix.noProject')}</div>;
213+
}
214+
const config = ProjectStore.audioFeature();
215+
const strips = stripsOf(config);
216+
217+
const addBus = () => {
218+
let n = 1;
219+
let name = 'bus';
220+
while (strips.some((s) => s.name === name)) name = `bus-${n++}`;
221+
commit(patchBus(config, name, { parent: 'master', volume: 1 }));
222+
};
223+
224+
return (
225+
<div className="mix-panel">
226+
<div className="mix-strips">
227+
{strips.map((s) => (
228+
<BusStrip key={s.name} strip={s} all={strips} config={config} />
229+
))}
230+
<button type="button" className="mix-add" title={t('mix.addBus')} onClick={addBus}>
231+
<Plus size={14} /> {t('mix.addBus')}
232+
</button>
233+
</div>
234+
</div>
235+
);
236+
}

0 commit comments

Comments
 (0)