diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index b24dd91d..b330ba39 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -42,6 +42,10 @@ AUDIO - Android's 13 presets with translated names, your own presets with Save, Save as, Rename, Duplicate and Delete with Undo, and a Custom entry that keeps the curve you tuned when you try a preset. AutoEQ and EqualizerAPO files import from a file or pasted text and export to a file or the clipboard. - The equalizer is saved with your settings, included in backups and synced with Flow for Android. Your old equalizer gains carry over, with the 32 Hz and 16 kHz bands now peaks like on Android, and the panel says when audio processing is not available on your system. - Music only goes through audio processing once the equalizer changes the sound, and playback resumes on its own after sleep or an output device change. +- Music has its own playback speed from 0.5x to 2x, with presets and the pitch kept, saved with your settings. +- The music volume slider follows how loud it sounds, so each step changes the level by a similar amount. Your saved volume plays at the same level after the update. +- Music loudness is measured the way Android does it, from the perceptual loudness first, and loud songs are never turned down by more than 20 dB. +- An optional crossfade, off by default, fades the next song in over 1 to 12 seconds. Consecutive tracks from the same album and repeat-one keep their gapless joins. MACOS - Flow uses the native macOS window controls and titlebar again. Closing the window hides it and keeps playback and downloads running until you quit, and clicking the Dock icon brings it back. diff --git a/src-tauri/src/api/innertube/json/loudness.rs b/src-tauri/src/api/innertube/json/loudness.rs index bd10f928..1b52cfbb 100644 --- a/src-tauri/src/api/innertube/json/loudness.rs +++ b/src-tauri/src/api/innertube/json/loudness.rs @@ -19,11 +19,59 @@ pub fn loudness_db(audio_config: &Value, format: &Value) -> Option { }) } +/// The target the site measures perceptual loudness against when the answer leaves it out. +const DEFAULT_TARGET_LKFS: f64 = -14.0; + +/// Music's loudness in Flow for Android's order (`LoudnessGain`): the perceptual loudness +/// against its target first, then the answer's `loudnessDb`, then the format's own. The +/// perceptual value is measured the way a listener hears the song, so it is preferred. +#[must_use] +pub fn music_loudness_db(audio_config: &Value, format: &Value) -> Option { + audio_config["perceptualLoudnessDb"] + .as_f64() + .map(|perceptual| { + perceptual + - audio_config["loudnessTargetLkfs"] + .as_f64() + .unwrap_or(DEFAULT_TARGET_LKFS) + }) + .or_else(|| audio_config["loudnessDb"].as_f64()) + .or_else(|| format["loudnessDb"].as_f64()) +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + // Ported from Android's LoudnessGainTest, in loudness rather than gain. + #[test] + fn music_loudness_prefers_the_perceptual_value_against_its_target() { + assert_eq!( + music_loudness_db( + &json!({ "loudnessDb": 9.0, "perceptualLoudnessDb": -8.0 }), + &json!({}) + ), + Some(6.0) + ); + assert_eq!( + music_loudness_db( + &json!({ "perceptualLoudnessDb": -10.0, "loudnessTargetLkfs": -16.0 }), + &json!({}) + ), + Some(6.0) + ); + assert_eq!( + music_loudness_db(&json!({ "loudnessDb": 4.5 }), &json!({ "loudnessDb": 9.0 })), + Some(4.5) + ); + assert_eq!( + music_loudness_db(&json!({}), &json!({ "loudnessDb": 3.0 })), + Some(3.0) + ); + assert_eq!(music_loudness_db(&json!({}), &json!({})), None); + } + #[test] fn loudness_comes_from_the_config_the_format_or_the_perceptual_target() { assert_eq!( diff --git a/src-tauri/src/api/innertube/music/playback.rs b/src-tauri/src/api/innertube/music/playback.rs index 8382a86e..35eca74c 100644 --- a/src-tauri/src/api/innertube/music/playback.rs +++ b/src-tauri/src/api/innertube/music/playback.rs @@ -13,7 +13,7 @@ use std::time::Duration; -use crate::api::innertube::json::loudness::loudness_db; +use crate::api::innertube::json::loudness::music_loudness_db; use serde_json::Value; use tracing::{debug, info, warn}; @@ -191,7 +191,7 @@ impl InnertubeClient { .as_str() .and_then(|s| s.parse::().ok()); let audio_config = &res["playerConfig"]["audioConfig"]; - let loudness_db = loudness_db(audio_config, &format); + let loudness_db = music_loudness_db(audio_config, &format); let perceptual_loudness_db = audio_config["perceptualLoudnessDb"].as_f64(); let expires_in_seconds = streaming["expiresInSeconds"] .as_str() @@ -436,21 +436,23 @@ mod tests { use serde_json::json; #[test] - fn loudness_falls_back_to_the_format_then_the_perceptual_value() { + fn music_loudness_takes_the_perceptual_value_before_the_others() { let top = json!({ "loudnessDb": 1.5, "perceptualLoudnessDb": -9.0, "loudnessTargetLkfs": -14.0 }); - assert_eq!(loudness_db(&top, &json!({ "loudnessDb": 3.0 })), Some(1.5)); - - let visionos = json!({ "perceptualLoudnessDb": -10.59, "loudnessTargetLkfs": -14.0 }); assert_eq!( - loudness_db(&visionos, &json!({ "loudnessDb": 3.41 })), - Some(3.41) + music_loudness_db(&top, &json!({ "loudnessDb": 3.0 })), + Some(5.0) ); - let perceptual = loudness_db(&visionos, &json!({})).unwrap(); + let visionos = json!({ "perceptualLoudnessDb": -10.59, "loudnessTargetLkfs": -14.0 }); + let perceptual = music_loudness_db(&visionos, &json!({ "loudnessDb": 1.0 })).unwrap(); assert!((perceptual - 3.41).abs() < 1e-9); - assert_eq!(loudness_db(&json!({}), &json!({})), None); + assert_eq!( + music_loudness_db(&json!({ "loudnessDb": 1.5 }), &json!({ "loudnessDb": 3.0 })), + Some(1.5) + ); + assert_eq!(music_loudness_db(&json!({}), &json!({})), None); } #[test] diff --git a/src/components/music/EqPanel.tsx b/src/components/music/EqPanel.tsx index e80cca07..b0284d7b 100644 --- a/src/components/music/EqPanel.tsx +++ b/src/components/music/EqPanel.tsx @@ -5,16 +5,18 @@ import { EqLimits } from "../../lib/audio/eq/models"; import { withBassBoost } from "../../lib/audio/eq/stateOps"; import { pickEqPreset, restoreCustomEq, useEqualizer, useMusicEqAvailable } from "../../lib/audio/eq/useEqualizer"; import { getString } from "../../lib/i18n/index"; -import { SETTINGS } from "../../lib/settings/schema"; -import { useBoolPref } from "../../lib/usePreference"; import { useEqStore } from "../../store/useEqStore"; import { Chip } from "../ui/Chip"; import { Slider } from "../ui/Slider"; import { ToggleSwitch } from "../ui/ToggleSwitch"; import { EqResponseGraph } from "./eq/EqResponseGraph"; import { formatDb } from "./eq/eqFormat"; +import { MusicAudioOptions } from "./MusicAudioOptions"; -/** The quick equalizer panel: switch, curve, presets and bass boost, with Edit for the rest. */ +/** + * The quick audio panel: the equalizer's switch, curve, presets and bass boost with Edit for the + * rest, then the other music audio settings. + */ export function EqPanel({ open, onClose, @@ -28,7 +30,6 @@ export function EqPanel({ const available = useMusicEqAvailable(); const update = useEqStore((s) => s.update); const setEditorOpen = useEqStore((s) => s.setEditorOpen); - const [normalization, setNormalization] = useBoolPref(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION); const fromY = placement === "top" ? 10 : -10; const selectedId = eq.active.presetId; @@ -127,12 +128,8 @@ export function EqPanel({ {getString("eq_edit")} -
- - {getString("music_loudness_normalization")} - {getString("music_loudness_normalization_desc")} - - +
+
)} diff --git a/src/components/music/MusicAudioOptions.tsx b/src/components/music/MusicAudioOptions.tsx new file mode 100644 index 00000000..71481a65 --- /dev/null +++ b/src/components/music/MusicAudioOptions.tsx @@ -0,0 +1,78 @@ +import { getString } from "../../lib/i18n/index"; +import { SETTINGS } from "../../lib/settings/schema"; +import { useBoolPref, useNumberPref } from "../../lib/usePreference"; +import { Chip } from "../ui/Chip"; +import { Slider } from "../ui/Slider"; +import { ToggleSwitch } from "../ui/ToggleSwitch"; + +const SPEED_PRESETS = [0.5, 0.75, 1, 1.25, 1.5, 2] as const; + +const formatSpeed = (rate: number) => getString("music_speed_value", Number(rate.toFixed(2))); + +/** Music audio settings beside the equalizer: loudness normalization, speed and crossfade. */ +export function MusicAudioOptions() { + const [normalization, setNormalization] = useBoolPref(SETTINGS.MUSIC_LOUDNESS_NORMALIZATION); + const [speed, setSpeed] = useNumberPref(SETTINGS.MUSIC_PLAYBACK_SPEED); + const [crossfade, setCrossfade] = useBoolPref(SETTINGS.MUSIC_CROSSFADE_ENABLED); + const [crossfadeSeconds, setCrossfadeSeconds] = useNumberPref(SETTINGS.MUSIC_CROSSFADE_SECONDS); + + return ( +
+
+ + {getString("music_loudness_normalization")} + {getString("music_loudness_normalization_desc")} + + +
+ +
+ + {getString("music_playback_speed")} + {formatSpeed(speed)} + +
+ {SPEED_PRESETS.map((rate) => ( + setSpeed(rate)} className="h-7 px-2.5 text-xs"> + {formatSpeed(rate)} + + ))} +
+ setSpeed(Math.round(rate * 100) / 100)} + aria-label={getString("music_playback_speed")} + /> +
+ +
+
+ + {getString("music_crossfade")} + {getString("music_crossfade_desc")} + + +
+ {crossfade && ( +
+ + + {getString("music_crossfade_seconds", crossfadeSeconds)} + +
+ )} +
+
+ ); +} diff --git a/src/lib/audio/crossfade.test.ts b/src/lib/audio/crossfade.test.ts new file mode 100644 index 00000000..173a219a --- /dev/null +++ b/src/lib/audio/crossfade.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { SongItem } from "../../types/music"; +import { crossfadeTarget, type CrossfadeQueue } from "./crossfade"; + +const song = (id: string, albumId: string | null = null): SongItem => ({ + id, + title: id, + artists: [], + album: albumId ? { id: albumId, name: albumId } : null, + duration: 200, + musicVideoType: "MUSIC_VIDEO_TYPE_ATV", + thumbnail: "", + explicit: false, + videoId: id, + playlistId: null, + params: null, +}); + +const near = (patch: Partial = {}): CrossfadeQueue => { + const queue = [song("a"), song("b")]; + return { + queue, + currentIndex: 0, + currentTrack: queue[0]!, + isShuffle: false, + repeatMode: "none", + isPlaying: true, + loadingStreamId: null, + progress: 196, + duration: 200, + ...patch, + }; +}; + +describe("crossfadeTarget", () => { + it("starts the fade once the song is inside its last seconds", () => { + expect(crossfadeTarget(near(), 5)).toBe(1); + expect(crossfadeTarget(near({ progress: 190 }), 5)).toBe(-1); + expect(crossfadeTarget(near({ progress: 199.9 }), 5)).toBe(-1); + }); + + it("stays off when disabled, paused, loading, on repeat-one or at the end of the queue", () => { + expect(crossfadeTarget(near(), 0)).toBe(-1); + expect(crossfadeTarget(near({ isPlaying: false }), 5)).toBe(-1); + expect(crossfadeTarget(near({ loadingStreamId: "a" }), 5)).toBe(-1); + expect(crossfadeTarget(near({ repeatMode: "one" }), 5)).toBe(-1); + expect(crossfadeTarget(near({ queue: [song("a")] }), 5)).toBe(-1); + }); + + it("keeps consecutive album tracks gapless unless shuffled", () => { + const album = [song("a", "LP"), song("b", "LP")]; + const inOrder = near({ queue: album, currentTrack: album[0]! }); + expect(crossfadeTarget(inOrder, 5)).toBe(-1); + expect(crossfadeTarget({ ...inOrder, isShuffle: true }, 5)).toBe(1); + }); + + it("never fades a song too short to hold the fade twice", () => { + expect(crossfadeTarget(near({ duration: 8, progress: 5 }), 5)).toBe(-1); + }); +}); diff --git a/src/lib/audio/crossfade.ts b/src/lib/audio/crossfade.ts new file mode 100644 index 00000000..c1618bf1 --- /dev/null +++ b/src/lib/audio/crossfade.ts @@ -0,0 +1,37 @@ +import type { SongItem } from "../../types/music"; +import { upcomingIndex, type QueueRepeatMode } from "../musicQueueOrder"; + +export interface CrossfadeQueue { + queue: SongItem[]; + currentIndex: number; + currentTrack: SongItem | null; + isShuffle: boolean; + repeatMode: QueueRepeatMode; + isPlaying: boolean; + loadingStreamId: string | null; + progress: number; + duration: number; +} + +/** + * Tracks in album order are often meant to run into each other (a live set, a concept album, a + * continuous mix), so they keep their gapless join instead of being faded across. + */ +const playsInAlbumOrder = (s: CrossfadeQueue, next: SongItem): boolean => + !s.isShuffle && !!s.currentTrack?.album?.id && s.currentTrack.album.id === next.album?.id; + +/** + * The queue index to crossfade into now, or -1. Fades start `seconds` before the end, never on + * repeat-one, never between consecutive album tracks, and never on a track too short to hold + * two fades. + */ +export function crossfadeTarget(s: CrossfadeQueue, seconds: number): number { + if (seconds <= 0 || !s.isPlaying || s.loadingStreamId !== null || s.repeatMode === "one") return -1; + if (!(s.duration > seconds * 2)) return -1; + const remaining = s.duration - s.progress; + if (remaining > seconds || remaining <= 0.25) return -1; + const index = upcomingIndex(s); + const next = s.queue[index]; + if (!next || index === s.currentIndex || playsInAlbumOrder(s, next)) return -1; + return index; +} diff --git a/src/lib/audio/crossfadeRamp.ts b/src/lib/audio/crossfadeRamp.ts new file mode 100644 index 00000000..c84a8e32 --- /dev/null +++ b/src/lib/audio/crossfadeRamp.ts @@ -0,0 +1,42 @@ +const CURVE_POINTS = 32; +const STEP_MS = 100; + +/** Equal-power gains for `t` in 0..1: the sum of both sides' power stays constant. */ +export const fadeInGain = (t: number): number => Math.sin((Math.max(0, Math.min(1, t)) * Math.PI) / 2); +export const fadeOutGain = (t: number): number => Math.cos((Math.max(0, Math.min(1, t)) * Math.PI) / 2); + +function curve(gain: (t: number) => number): Float32Array { + const values = new Float32Array(CURVE_POINTS); + for (let i = 0; i < CURVE_POINTS; i++) values[i] = gain(i / (CURVE_POINTS - 1)); + return values; +} + +/** + * Schedules a whole fade on the audio clock, so it runs smoothly even while the window is hidden + * and timers are throttled. + */ +export function scheduleFade(param: AudioParam, direction: "in" | "out", startTime: number, seconds: number): void { + param.cancelScheduledValues(startTime); + param.setValueCurveAtTime(curve(direction === "in" ? fadeInGain : fadeOutGain), startTime, seconds); +} + +/** Puts `param` back at full level at once, dropping any fade still scheduled on it. */ +export function resetFade(param: AudioParam, time: number): void { + param.cancelScheduledValues(time); + param.setValueAtTime(1, time); +} + +/** + * The fallback without WebAudio: element volume moved in steps. Returns a cancel function. + * `onStep` gets the fade's progress from 0 to 1. + */ +export function steppedFade(seconds: number, onStep: (progress: number) => void): () => void { + const started = Date.now(); + onStep(0); + const timer = setInterval(() => { + const progress = Math.min(1, (Date.now() - started) / (seconds * 1_000)); + onStep(progress); + if (progress >= 1) clearInterval(timer); + }, STEP_MS); + return () => clearInterval(timer); +} diff --git a/src/lib/audio/musicAudioEngine.eq.test.ts b/src/lib/audio/musicAudioEngine.eq.test.ts index 34da8779..075d27a6 100644 --- a/src/lib/audio/musicAudioEngine.eq.test.ts +++ b/src/lib/audio/musicAudioEngine.eq.test.ts @@ -10,6 +10,13 @@ class FakeParam { setTargetAtTime = vi.fn((value: number) => { this.value = value; }); + setValueAtTime = vi.fn((value: number) => { + this.value = value; + }); + cancelScheduledValues = vi.fn(); + setValueCurveAtTime = vi.fn((curve: Float32Array, _start: number, _seconds: number) => { + this.value = curve[curve.length - 1] ?? this.value; + }); } class FakeNode { connect = vi.fn((node: unknown) => node); @@ -34,6 +41,7 @@ class FakeContext extends EventTarget { sampleRate = 48_000; destination = new FakeNode(); biquads: FakeBiquad[] = []; + gains: FakeGain[] = []; sources: HTMLMediaElement[] = []; resume = vi.fn(async () => { this.state = "running"; @@ -43,7 +51,9 @@ class FakeContext extends EventTarget { contexts.push(this); } createGain() { - return new FakeGain(); + const g = new FakeGain(); + this.gains.push(g); + return g; } createBiquadFilter() { const f = new FakeBiquad(); @@ -95,15 +105,18 @@ describe("music EQ graph", () => { musicAudioEngine.setLoudness(6, true); expect(contexts).toHaveLength(0); - // Normalization turns the element down by itself: 6 dB over the target is about half. - expect(pair[0].volume).toBeCloseTo(0.25, 2); + // Half way on the slider is a quarter of the gain, and 6 dB over the target halves it again. + expect(pair[0].volume).toBeCloseTo(0.125, 2); expect(musicAudioEngine.isEqAvailable()).toBe(true); }); - it("builds one chain of 20 filters plus bass boost, and keeps it through a gapless swap", async () => { + it("builds one chain of 20 filters plus bass boost on play, and keeps it through a gapless swap", async () => { const pair = elements(); musicAudioEngine.attach(pair); musicAudioEngine.setEq(rock, true); + // Nothing is playing yet: the context waits for the play that a click starts. + expect(contexts).toHaveLength(0); + await musicAudioEngine.play(); const ctx = lastContext(); expect(ctx.sources).toEqual(pair); @@ -130,6 +143,7 @@ describe("music EQ graph", () => { const pair = elements(); musicAudioEngine.attach(pair); musicAudioEngine.setEq(rock, true); + await musicAudioEngine.play(); const ctx = lastContext(); ctx.state = "suspended"; ctx.resume.mockImplementation(() => new Promise(() => {})); @@ -143,12 +157,68 @@ describe("music EQ graph", () => { it("resumes the context when the system suspends it mid-song", () => { const pair = elements(); musicAudioEngine.attach(pair); + Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); + // Turned on while a song plays, the graph is built at once. musicAudioEngine.setEq(rock, true); const ctx = lastContext(); - Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); + expect(ctx.sources).toContain(pair[0]); ctx.state = "suspended"; ctx.dispatchEvent(new Event("statechange")); expect(ctx.resume).toHaveBeenCalled(); }); + + it("crossfades into a buffered song on the audio clock, then empties the one that ended", async () => { + vi.useFakeTimers(); + const pair = elements(); + musicAudioEngine.attach(pair); + Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); + musicAudioEngine.setCrossfadeEnabled(true); + const ctx = lastContext(); + await musicAudioEngine.load("http://127.0.0.1/stream/a"); + musicAudioEngine.preload("b", "http://127.0.0.1/stream/b", null); + Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true }); + + musicAudioEngine.armCrossfade(4); + expect(musicAudioEngine.activatePreloaded("b")).toBe(true); + + expect(musicAudioEngine.getActiveElement()).toBe(pair[1]); + expect(pair[0].getAttribute("src")).toBe("http://127.0.0.1/stream/a"); + const fades = ctx.gains.filter((g) => g.gain.setValueCurveAtTime.mock.calls.length > 0); + expect(fades).toHaveLength(2); + expect(fades.every((g) => g.gain.setValueCurveAtTime.mock.calls[0]![2] === 4)).toBe(true); + + await vi.advanceTimersByTimeAsync(4_100); + expect(pair[0].getAttribute("src")).toBeNull(); + musicAudioEngine.setCrossfadeEnabled(false); + }); +}); + +describe("crossfade without WebAudio", () => { + it("steps the two elements' volume instead", async () => { + vi.useFakeTimers(); + vi.stubGlobal("AudioContext", undefined); + vi.resetModules(); + const { musicAudioEngine: engine } = await import("./musicAudioEngine"); + const pair = elements(); + engine.attach(pair); + engine.setCrossfadeEnabled(true); + expect(engine.isEqAvailable()).toBe(false); + + await engine.load("http://127.0.0.1/stream/a"); + engine.preload("b", "http://127.0.0.1/stream/b", null); + Object.defineProperty(pair[1], "readyState", { value: HTMLMediaElement.HAVE_FUTURE_DATA, configurable: true }); + Object.defineProperty(pair[0], "paused", { value: false, configurable: true }); + engine.armCrossfade(2); + engine.activatePreloaded("b"); + + expect(pair[1].volume).toBe(0); + await vi.advanceTimersByTimeAsync(1_000); + expect(pair[0].volume).toBeGreaterThan(0.6); + expect(pair[0].volume).toBeLessThan(0.8); + expect(pair[1].volume).toBeCloseTo(pair[0].volume, 1); + await vi.advanceTimersByTimeAsync(1_200); + expect(pair[1].volume).toBe(1); + expect(pair[0].getAttribute("src")).toBeNull(); + }); }); diff --git a/src/lib/audio/musicAudioEngine.test.ts b/src/lib/audio/musicAudioEngine.test.ts index 57661493..9fea2535 100644 --- a/src/lib/audio/musicAudioEngine.test.ts +++ b/src/lib/audio/musicAudioEngine.test.ts @@ -66,3 +66,38 @@ describe("gapless standby element", () => { expect(musicAudioEngine.clearPreload()).toBeNull(); }); }); + +describe("music speed", () => { + // Counts writes: on WebKitGTK every playbackRate write flushes the audio pipeline. + const countRateWrites = (el: HTMLAudioElement) => { + let rate = 1; + const writes = { count: 0 }; + Object.defineProperty(el, "playbackRate", { + configurable: true, + get: () => rate, + set: (value: number) => { + writes.count++; + rate = value; + }, + }); + return writes; + }; + + it("writes the rate only when it changes, and carries it across loads", () => { + const fresh = elements(); + const writes = countRateWrites(fresh[0]); + musicAudioEngine.attach(fresh); + musicAudioEngine.setSpeed(1); + expect(writes.count).toBe(0); + + musicAudioEngine.setSpeed(1.5); + musicAudioEngine.setSpeed(1.5); + expect(writes.count).toBe(1); + expect(fresh[0].defaultPlaybackRate).toBe(1.5); + expect(fresh[0].preservesPitch).toBe(true); + + musicAudioEngine.setSpeed(9); + expect(fresh[0].playbackRate).toBe(2); + musicAudioEngine.setSpeed(1); + }); +}); diff --git a/src/lib/audio/musicAudioEngine.ts b/src/lib/audio/musicAudioEngine.ts index 50deaa65..3612dba7 100644 --- a/src/lib/audio/musicAudioEngine.ts +++ b/src/lib/audio/musicAudioEngine.ts @@ -1,7 +1,10 @@ -import { loudnessFactor } from "../loudness"; +import { loudnessFactor, MUSIC_MAX_CUT_DB } from "../loudness"; +import { IS_LINUX_RUNTIME } from "../platform"; import { createAudioContext, keepRunning, resumeWithin, webAudioSupported } from "./audioContext"; +import { fadeInGain, fadeOutGain, resetFade, scheduleFade, steppedFade } from "./crossfadeRamp"; import { EQ_GRAPH_OFF, type EqGraphSpec } from "./eq/graphPlan"; import { EqChain, RAMP_TIME_CONSTANT } from "./eqChain"; +import { volumeGain } from "./volumeCurve"; /** * One of the two