Skip to content

Commit 974275b

Browse files
committed
spatial audio
1 parent fc4a129 commit 974275b

5 files changed

Lines changed: 181 additions & 24 deletions

File tree

src/ctx/settings_ctx.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { createContext, useContext, useState, useEffect, type ReactNode } from "react";
22
import { invoke } from "@tauri-apps/api/core";
33
import { message } from "@tauri-apps/plugin-dialog";
4-
import { set_eq_all, set_pitch as audio_set_pitch, set_crossfade as audio_set_cf, EQ_FREQS } from "@/lib";
4+
import { set_eq_all, set_pitch as audio_set_pitch, set_crossfade as audio_set_cf, set_spatial as audio_set_spatial, EQ_FREQS } from "@/lib";
55

66
export type EqPreset = { name: string; gains: number[] };
77

@@ -82,6 +82,8 @@ type SettingsState = {
8282
set_amll_word_sync: (v: boolean) => void;
8383
crossfade: number;
8484
set_crossfade: (v: number) => void;
85+
spatial_audio: boolean;
86+
set_spatial_audio: (v: boolean) => void;
8587
custom_presets: EqPreset[];
8688
save_preset: (name: string, gains: number[]) => void;
8789
delete_preset: (name: string) => void;
@@ -121,6 +123,7 @@ export function SettingsProv({ children }: { children: ReactNode }) {
121123
const s = localStorage.getItem("crossfade");
122124
return s ? Math.max(0, Math.min(12, parseFloat(s) || 0)) : 0;
123125
});
126+
const [spatial_audio, _set_spatial] = useState(() => localStorage.getItem("spatial_audio") === "1");
124127
const [custom_presets, _set_custom_presets] = useState<EqPreset[]>(load_custom_presets);
125128

126129
useEffect(() => {
@@ -140,6 +143,10 @@ export function SettingsProv({ children }: { children: ReactNode }) {
140143
audio_set_cf(crossfade);
141144
}, [crossfade]);
142145

146+
useEffect(() => {
147+
audio_set_spatial(spatial_audio);
148+
}, [spatial_audio]);
149+
143150
useEffect(() => {
144151
if (!tray_enabled) {
145152
invoke("tray_set", { enabled: false }).catch(e => console.error("tray_set off:", e));
@@ -167,6 +174,7 @@ export function SettingsProv({ children }: { children: ReactNode }) {
167174
const set_exp_volume = (v: boolean) => persist_bool("exp_volume", v, _set_exp_vol);
168175
const set_amll_lyrics = (v: boolean) => persist_bool("amll_lyrics", v, _set_amll);
169176
const set_amll_word_sync = (v: boolean) => persist_bool("amll_word_sync", v, _set_amll_ws);
177+
const set_spatial_audio = (v: boolean) => persist_bool("spatial_audio", v, _set_spatial);
170178

171179
function set_eq_bands(gains: number[]) {
172180
localStorage.setItem("eq_bands", JSON.stringify(gains));
@@ -243,7 +251,7 @@ export function SettingsProv({ children }: { children: ReactNode }) {
243251
}
244252

245253
return (
246-
<Ctx.Provider value={{ immersive_bg, set_immersive_bg, eq_bands, set_eq_bands, eq_enabled, set_eq_enabled, discord_rpc, set_discord_rpc, rpc_opts, set_rpc_opts, tray_enabled, set_tray_enabled, sp_client_id, set_sp_client_id, sp_tokens, sp_token, sp_connect, sp_disconnect, sp_loading, pitch, set_pitch, exp_volume, set_exp_volume, amll_lyrics, set_amll_lyrics, amll_word_sync, set_amll_word_sync, crossfade, set_crossfade, custom_presets, save_preset, delete_preset }}>
254+
<Ctx.Provider value={{ immersive_bg, set_immersive_bg, eq_bands, set_eq_bands, eq_enabled, set_eq_enabled, discord_rpc, set_discord_rpc, rpc_opts, set_rpc_opts, tray_enabled, set_tray_enabled, sp_client_id, set_sp_client_id, sp_tokens, sp_token, sp_connect, sp_disconnect, sp_loading, pitch, set_pitch, exp_volume, set_exp_volume, amll_lyrics, set_amll_lyrics, amll_word_sync, set_amll_word_sync, crossfade, set_crossfade, spatial_audio, set_spatial_audio, custom_presets, save_preset, delete_preset }}>
247255
{children}
248256
</Ctx.Provider>
249257
);

src/lib/audio.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { invoke } from "@tauri-apps/api/core";
2+
import { create_spatial, get_spatial_nodes, is_spatial_enabled, set_spatial_enabled } from "./spatial";
23

34
type AudioCb = {
45
on_time?: (t: number) => void;
@@ -50,7 +51,16 @@ function wire_deck(el: HTMLAudioElement): Deck {
5051
let node: AudioNode = src;
5152
for (const f of filters) { node.connect(f); node = f; }
5253
node.connect(gain);
53-
gain.connect(ctx.destination);
54+
55+
if (is_spatial_enabled()) {
56+
let spatial = get_spatial_nodes();
57+
if (!spatial) spatial = create_spatial(ctx);
58+
gain.connect(spatial.input);
59+
spatial.output.connect(ctx.destination);
60+
} else {
61+
gain.connect(ctx.destination);
62+
}
63+
5464
return { el, gain, filters };
5565
}
5666

@@ -251,3 +261,25 @@ export function set_pitch(rate: number) {
251261
if (_a) { _a.el.playbackRate = clamped; _a.el.preservesPitch = false; }
252262
if (_b) { _b.el.playbackRate = clamped; _b.el.preservesPitch = false; }
253263
}
264+
265+
export function set_spatial(on: boolean) {
266+
set_spatial_enabled(on);
267+
const ctx = get_ctx();
268+
269+
function rewire(deck: Deck | null) {
270+
if (!deck?.gain) return;
271+
deck.gain.disconnect();
272+
if (on) {
273+
let spatial = get_spatial_nodes();
274+
if (!spatial) spatial = create_spatial(ctx);
275+
spatial.output.disconnect();
276+
deck.gain.connect(spatial.input);
277+
spatial.output.connect(ctx.destination);
278+
} else {
279+
deck.gain.connect(ctx.destination);
280+
}
281+
}
282+
283+
rewire(_a);
284+
rewire(_b);
285+
}

src/lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
export { play_src, play_resume, play_pause, play_seek, get_vol, set_vol, get_playing, get_time, get_latency, set_cbs, set_eq_band, set_eq_all, get_pitch, set_pitch, is_live, EQ_FREQS, set_crossfade, get_crossfade } from "./audio";
1+
export { play_src, play_resume, play_pause, play_seek, get_vol, set_vol, get_playing, get_time, get_latency, set_cbs, set_eq_band, set_eq_all, get_pitch, set_pitch, is_live, EQ_FREQS, set_crossfade, get_crossfade, set_spatial } from "./audio";
22
export { make_albums, make_artists } from "./parse";
33
export { format_duration, format_count } from "./fmt";
44
export { use_cover } from "./cover";

src/lib/spatial.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
type SpatialNodes = {
2+
input: GainNode;
3+
output: GainNode;
4+
splitter: ChannelSplitterNode;
5+
merger: ChannelMergerNode;
6+
delay_l: DelayNode;
7+
delay_r: DelayNode;
8+
cross_l: GainNode;
9+
cross_r: GainNode;
10+
direct_l: GainNode;
11+
direct_r: GainNode;
12+
convolver: ConvolverNode;
13+
wet: GainNode;
14+
dry: GainNode;
15+
};
16+
17+
let _nodes: SpatialNodes | null = null;
18+
let _enabled = false;
19+
20+
function gen_impulse(ctx: AudioContext): AudioBuffer {
21+
const rate = ctx.sampleRate;
22+
const len = Math.round(rate * 0.35);
23+
const buf = ctx.createBuffer(2, len, rate);
24+
const l = buf.getChannelData(0);
25+
const r = buf.getChannelData(1);
26+
27+
for (let i = 0; i < len; i++) {
28+
const t = i / rate;
29+
const decay = Math.exp(-6 * t);
30+
const early = i < rate * 0.02 ? 0.6 : 1;
31+
32+
l[i] = (Math.random() * 2 - 1) * decay * early * 0.15;
33+
r[i] = (Math.random() * 2 - 1) * decay * early * 0.15;
34+
35+
if (i === Math.round(rate * 0.008)) { l[i] += 0.12; r[i] += 0.08; }
36+
if (i === Math.round(rate * 0.013)) { l[i] += 0.06; r[i] += 0.10; }
37+
if (i === Math.round(rate * 0.019)) { l[i] += 0.08; r[i] += 0.05; }
38+
}
39+
40+
return buf;
41+
}
42+
43+
export function create_spatial(ctx: AudioContext): SpatialNodes {
44+
const input = ctx.createGain();
45+
const output = ctx.createGain();
46+
47+
const splitter = ctx.createChannelSplitter(2);
48+
const merger = ctx.createChannelMerger(2);
49+
50+
const delay_l = ctx.createDelay(0.01);
51+
const delay_r = ctx.createDelay(0.01);
52+
delay_l.delayTime.value = 0.00022;
53+
delay_r.delayTime.value = 0.00038;
54+
55+
const cross_l = ctx.createGain();
56+
const cross_r = ctx.createGain();
57+
cross_l.gain.value = -0.15;
58+
cross_r.gain.value = -0.15;
59+
60+
const direct_l = ctx.createGain();
61+
const direct_r = ctx.createGain();
62+
direct_l.gain.value = 0.92;
63+
direct_r.gain.value = 0.92;
64+
65+
input.connect(splitter);
66+
67+
splitter.connect(delay_l, 0);
68+
delay_l.connect(direct_l);
69+
direct_l.connect(merger, 0, 0);
70+
71+
splitter.connect(cross_r, 0);
72+
cross_r.connect(merger, 0, 1);
73+
74+
splitter.connect(delay_r, 1);
75+
delay_r.connect(direct_r);
76+
direct_r.connect(merger, 0, 1);
77+
78+
splitter.connect(cross_l, 1);
79+
cross_l.connect(merger, 0, 0);
80+
81+
const convolver = ctx.createConvolver();
82+
convolver.buffer = gen_impulse(ctx);
83+
84+
const wet = ctx.createGain();
85+
const dry = ctx.createGain();
86+
wet.gain.value = 0.22;
87+
dry.gain.value = 0.85;
88+
89+
merger.connect(dry);
90+
merger.connect(convolver);
91+
convolver.connect(wet);
92+
93+
dry.connect(output);
94+
wet.connect(output);
95+
96+
_nodes = { input, output, splitter, merger, delay_l, delay_r, cross_l, cross_r, direct_l, direct_r, convolver, wet, dry };
97+
return _nodes;
98+
}
99+
100+
export function get_spatial_nodes(): SpatialNodes | null {
101+
return _nodes;
102+
}
103+
104+
export function is_spatial_enabled(): boolean {
105+
return _enabled;
106+
}
107+
108+
export function set_spatial_enabled(v: boolean) {
109+
_enabled = v;
110+
}

src/pages/audio/index.tsx

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,38 @@ import { Pitch } from "./pitch";
44
import { Crossfade } from "./crossfade";
55
import { c } from "@/theme";
66

7+
function HeaderToggle({ label, on, toggle }: { label: string; on: boolean; toggle: () => void }) {
8+
return (
9+
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
10+
<span style={{ fontSize: 12, color: c.w40 }}>{label}</span>
11+
<button
12+
onClick={toggle}
13+
style={{
14+
width: 36, height: 22, borderRadius: 11, position: "relative", flexShrink: 0,
15+
background: on ? c.accent : c.w12,
16+
transition: "background 0.2s",
17+
}}
18+
>
19+
<div style={{
20+
position: "absolute", top: 2, left: on ? 16 : 2,
21+
width: 18, height: 18, borderRadius: "50%", background: c.white,
22+
transition: "left 0.2s cubic-bezier(0.4,0,0.2,1)",
23+
boxShadow: `0 1px 4px ${c.b40}`,
24+
}} />
25+
</button>
26+
</div>
27+
);
28+
}
29+
730
export function Audio() {
8-
const { eq_enabled, set_eq_enabled } = use_settings();
31+
const { eq_enabled, set_eq_enabled, spatial_audio, set_spatial_audio } = use_settings();
932

1033
return (
1134
<div style={{ display: "flex", flexDirection: "column", flex: 1, minHeight: 0 }}>
12-
<div className="page-header" style={{ height: 52, display: "flex", alignItems: "center", padding: "0 24px", flexShrink: 0, borderBottom: `1px solid ${c.w07}`, gap: 12 }} data-tauri-drag-region>
35+
<div className="page-header" style={{ height: 52, display: "flex", alignItems: "center", padding: "0 24px", flexShrink: 0, borderBottom: `1px solid ${c.w07}`, gap: 16 }} data-tauri-drag-region>
1336
<h1 style={{ fontSize: 20, fontWeight: 700, letterSpacing: "-0.3px", color: c.text, flex: 1 }}>Audio</h1>
14-
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
15-
<span style={{ fontSize: 12, color: c.w40 }}>Equalizer</span>
16-
<button
17-
onClick={() => set_eq_enabled(!eq_enabled)}
18-
style={{
19-
width: 36, height: 22, borderRadius: 11, position: "relative", flexShrink: 0,
20-
background: eq_enabled ? c.accent : c.w12,
21-
transition: "background 0.2s",
22-
}}
23-
>
24-
<div style={{
25-
position: "absolute", top: 2, left: eq_enabled ? 16 : 2,
26-
width: 18, height: 18, borderRadius: "50%", background: c.white,
27-
transition: "left 0.2s cubic-bezier(0.4,0,0.2,1)",
28-
boxShadow: `0 1px 4px ${c.b40}`,
29-
}} />
30-
</button>
31-
</div>
37+
<HeaderToggle label="Spatial Audio" on={spatial_audio} toggle={() => set_spatial_audio(!spatial_audio)} />
38+
<HeaderToggle label="Equalizer" on={eq_enabled} toggle={() => set_eq_enabled(!eq_enabled)} />
3239
</div>
3340

3441
<div style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: "24px 28px 48px" }}>

0 commit comments

Comments
 (0)