diff --git a/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx b/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx index abdc68b..f11890a 100644 --- a/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx +++ b/frontend/next-app/src/app/practice-timer/__tests__/page.test.tsx @@ -30,6 +30,7 @@ jest.mock('@/lib/audio/metronome-engine', () => ({ setBpm: jest.fn(), setBeatsPerMeasure: jest.fn(), setOnBeat: jest.fn(), + setVolume: jest.fn(), })), })); @@ -53,6 +54,8 @@ jest.mock('@/lib/practice-session-store', () => ({ clearStoredSessionSnapshot: jest.fn(), getProject: (...args: unknown[]) => mockGetProject(...args), saveProject: (...args: unknown[]) => mockSaveProject(...args), + getMetronomeVolume: jest.fn().mockReturnValue(null), + saveMetronomeVolume: jest.fn(), INSTRUMENTS: ['Guitar', 'Bass', 'Drums', 'Keys'] as const, })); diff --git a/frontend/next-app/src/app/practice-timer/page.tsx b/frontend/next-app/src/app/practice-timer/page.tsx index 6f68861..7a1763f 100644 --- a/frontend/next-app/src/app/practice-timer/page.tsx +++ b/frontend/next-app/src/app/practice-timer/page.tsx @@ -22,7 +22,9 @@ import { AnimatePresence } from "framer-motion"; import { clearStoredPracticeSetup, clearStoredSessionSnapshot, + getMetronomeVolume, getStoredPracticeSetup, + saveMetronomeVolume, saveStoredPracticeSetup, saveStoredSessionSnapshot, getProject, @@ -77,6 +79,18 @@ function PracticeTimerContent() { const [currentBeat, setCurrentBeat] = useState(-1); const [beatsPerMeasure, setBeatsPerMeasure] = useState(4); const [, setTapTimes] = useState([]); + const [metronomeVolume, setMetronomeVolumeState] = useState(0.8); + + // Hydrate from localStorage after mount (SSR-safe) + useEffect(() => { + const saved = getMetronomeVolume(); + if (saved !== null) setMetronomeVolumeState(saved); + }, []); + + const handleMetronomeVolumeChange = useCallback((v: number) => { + setMetronomeVolumeState(v); + saveMetronomeVolume(v); + }, []); // ─── Tuner state ───────────────────────────────────────────────────── const [tunerActive, setTunerActive] = useState(false); @@ -617,10 +631,11 @@ function PracticeTimerContent() { beatsPerMeasure, onBeat: handleBeatCallback, }); + engine.setVolume(metronomeVolume); metronomeRef.current = engine; engine.start(); setMetronomeActive(true); - }, [bpm, beatsPerMeasure, handleBeatCallback]); + }, [bpm, beatsPerMeasure, handleBeatCallback, metronomeVolume]); const handleMetronomeStop = useCallback(() => { metronomeRef.current?.stop(); @@ -641,6 +656,10 @@ function PracticeTimerContent() { metronomeRef.current?.setOnBeat(handleBeatCallback); }, [handleBeatCallback]); + useEffect(() => { + metronomeRef.current?.setVolume(metronomeVolume); + }, [metronomeVolume]); + const handleBpmChange = (value: number) => { const clamped = Math.max(20, Math.min(300, value)); setBpm(clamped); @@ -1001,6 +1020,8 @@ function PracticeTimerContent() { onBeatsPerMeasureChange={setBeatsPerMeasure} onToggle={handleMetronomeToggle} onTapTempo={handleTapTempo} + volume={metronomeVolume} + onVolumeChange={handleMetronomeVolumeChange} />
diff --git a/frontend/next-app/src/components/studio/MetronomeWidget.test.tsx b/frontend/next-app/src/components/studio/MetronomeWidget.test.tsx new file mode 100644 index 0000000..537776e --- /dev/null +++ b/frontend/next-app/src/components/studio/MetronomeWidget.test.tsx @@ -0,0 +1,62 @@ +/** + * @jest-environment jsdom + */ +import { render, screen, fireEvent } from "@testing-library/react"; +import MetronomeWidget from "./MetronomeWidget"; + +// Props mirror the real MetronomeWidgetProps interface. If the interface +// has diverged by the time this runs, read the widget and reconcile — the +// point of this test is the volume slider, not the rest. +const baseProps = { + bpm: 120, + isActive: false, + currentBeat: 0, + beatsPerMeasure: 4, + onBpmChange: jest.fn(), + onBeatsPerMeasureChange: jest.fn(), + onToggle: jest.fn(), + onTapTempo: jest.fn(), + volume: 0.8, + onVolumeChange: jest.fn(), +}; + +describe("MetronomeWidget — volume slider", () => { + it("renders a slider with aria-label 'Metronome volume' and the supplied value", () => { + render(); + const slider = screen.getByLabelText("Metronome volume") as HTMLInputElement; + expect(slider).toBeInTheDocument(); + expect(slider.type).toBe("range"); + expect(slider.min).toBe("0"); + expect(slider.max).toBe("1"); + expect(Number(slider.value)).toBeCloseTo(0.3, 2); + }); + + it("calls onVolumeChange with the new numeric value when dragged", () => { + const onVolumeChange = jest.fn(); + render( + , + ); + const slider = screen.getByLabelText("Metronome volume"); + fireEvent.change(slider, { target: { value: "0.72" } }); + expect(onVolumeChange).toHaveBeenCalledWith(0.72); + }); + + it("shows the 'muted' icon when volume is 0", () => { + render(); + expect(screen.getByTestId("volume-icon-muted")).toBeInTheDocument(); + }); + + it("shows the 'low' icon when 0 < volume < 0.5", () => { + render(); + expect(screen.getByTestId("volume-icon-low")).toBeInTheDocument(); + }); + + it("shows the 'high' icon when volume >= 0.5", () => { + render(); + expect(screen.getByTestId("volume-icon-high")).toBeInTheDocument(); + }); +}); diff --git a/frontend/next-app/src/components/studio/MetronomeWidget.tsx b/frontend/next-app/src/components/studio/MetronomeWidget.tsx index c5cb30e..741485c 100644 --- a/frontend/next-app/src/components/studio/MetronomeWidget.tsx +++ b/frontend/next-app/src/components/studio/MetronomeWidget.tsx @@ -4,6 +4,7 @@ import React from "react"; import { Button } from "@/components/ui/button"; import { Minus, Plus } from "@phosphor-icons/react"; import { MotionDiv } from "@/components/ui/motion-wrapper"; +import { VolumeIcon } from "./VolumeIcon"; const TIME_SIGNATURES = [ { label: "2/4", beats: 2 }, @@ -23,6 +24,8 @@ interface MetronomeWidgetProps { onBeatsPerMeasureChange: (beats: number) => void; onToggle: () => void; onTapTempo: () => void; + volume: number; + onVolumeChange: (volume: number) => void; } export default function MetronomeWidget({ @@ -34,6 +37,8 @@ export default function MetronomeWidget({ onBeatsPerMeasureChange, onToggle, onTapTempo, + volume, + onVolumeChange, }: MetronomeWidgetProps) { const handleBpmChange = (value: number) => { onBpmChange(Math.max(20, Math.min(300, value))); @@ -79,6 +84,20 @@ export default function MetronomeWidget({
+
+ + onVolumeChange(Number(e.target.value))} + className="flex-1 h-1.5 bg-muted rounded-full appearance-none cursor-pointer accent-primary" + aria-label="Metronome volume" + /> +
+
{Array.from({ length: beatsPerMeasure }).map((_, i) => { const isActiveBeat = currentBeat === i; diff --git a/frontend/next-app/src/components/studio/VolumeIcon.tsx b/frontend/next-app/src/components/studio/VolumeIcon.tsx new file mode 100644 index 0000000..1e247ed --- /dev/null +++ b/frontend/next-app/src/components/studio/VolumeIcon.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { SpeakerHigh, SpeakerLow, SpeakerX } from "@phosphor-icons/react"; + +export interface VolumeIconProps { + volume: number; // 0..1 + size?: number; + className?: string; +} + +export function VolumeIcon({ + volume, + size = 16, + className, +}: VolumeIconProps) { + if (volume === 0) { + return ( + + ); + } + if (volume < 0.5) { + return ( + + ); + } + return ( + + ); +} diff --git a/frontend/next-app/src/lib/audio/metronome-engine.test.ts b/frontend/next-app/src/lib/audio/metronome-engine.test.ts new file mode 100644 index 0000000..c35d933 --- /dev/null +++ b/frontend/next-app/src/lib/audio/metronome-engine.test.ts @@ -0,0 +1,259 @@ +/** + * @jest-environment jsdom + */ +import { MetronomeEngine } from "./metronome-engine"; + +// Minimal Web Audio mock — enough surface for MetronomeEngine. +// Each node tracks what it's connected to so routing can be asserted. + +const makeParam = () => ({ + value: 0, + setValueAtTime: jest.fn(), + exponentialRampToValueAtTime: jest.fn(), + linearRampToValueAtTime: jest.fn(), +}); + +const makeOscillator = () => ({ + frequency: makeParam(), + connect: jest.fn(), + start: jest.fn(), + stop: jest.fn(), +}); + +const makeGain = () => ({ + gain: makeParam(), + connect: jest.fn(), + disconnect: jest.fn(), +}); + +type MockOscillator = ReturnType; +type MockGain = ReturnType; + +let createdOscillators: MockOscillator[]; +let createdGains: MockGain[]; +let mockContext: { + currentTime: number; + destination: { __tag: "destination" }; + createOscillator: jest.Mock; + createGain: jest.Mock; + close: jest.Mock; +}; + +const installAudioContextMock = () => { + createdOscillators = []; + createdGains = []; + mockContext = { + currentTime: 0, + destination: { __tag: "destination" }, + createOscillator: jest.fn(() => { + const o = makeOscillator(); + createdOscillators.push(o); + return o; + }), + createGain: jest.fn(() => { + const g = makeGain(); + createdGains.push(g); + return g; + }), + close: jest.fn(), + }; + (global as unknown as { AudioContext: unknown }).AudioContext = jest.fn( + () => mockContext, + ); +}; + +describe("MetronomeEngine — master gain routing", () => { + beforeEach(() => { + installAudioContextMock(); + }); + + it("creates a masterGain node FIRST on start() (before any per-click envelope) and connects it to destination exactly once", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.start(); + + // start() schedules at least one click synchronously (BPM 120, SCHEDULE_AHEAD_TIME 0.1s), + // so createdGains must contain masterGain + >=1 envelope gain. + expect(createdGains.length).toBeGreaterThanOrEqual(2); + + const masterGain = createdGains[0]; + expect(masterGain).toBeDefined(); + expect(masterGain.connect).toHaveBeenCalledWith(mockContext.destination); + // masterGain routes to destination exactly once — guards against a regression + // where the scheduler reverts to direct-to-destination per click. + expect(masterGain.connect).toHaveBeenCalledTimes(1); + }); + + it("routes per-click envelope gains into masterGain, not directly to destination", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.start(); + + const masterGain = createdGains[0]; + // schedule() fires at least one click synchronously; the second gain node + // onward is a per-click envelope + const envelopeGain = createdGains[1]; + expect(envelopeGain).toBeDefined(); + expect(envelopeGain.connect).toHaveBeenCalledWith(masterGain); + expect(envelopeGain.connect).not.toHaveBeenCalledWith( + mockContext.destination, + ); + }); + + it("disconnects masterGain and closes the audio context on stop()", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.start(); + const masterGain = createdGains[0]; + engine.stop(); + expect(masterGain.disconnect).toHaveBeenCalled(); + expect(mockContext.close).toHaveBeenCalled(); + }); + + it("creates a fresh masterGain on the next start() after a stop()", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.start(); + const firstMasterGain = createdGains[0]; + engine.stop(); + + // Reinstall the mock so createdGains starts empty for the second start() + installAudioContextMock(); + engine.start(); + const secondMasterGain = createdGains[0]; + + expect(secondMasterGain).toBeDefined(); + expect(secondMasterGain).not.toBe(firstMasterGain); + }); +}); + +describe("MetronomeEngine — setVolume", () => { + beforeEach(() => { + installAudioContextMock(); + }); + + it("applies a square-law curve on start(): masterGain initial value = volume²", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.setVolume(0.5); + engine.start(); + const masterGain = createdGains[0]; + expect(masterGain.gain.value).toBeCloseTo(0.25, 10); + }); + + it("clamps setVolume input to [0, 1]", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.setVolume(2); + engine.start(); + expect(createdGains[0].gain.value).toBeCloseTo(1, 10); + + installAudioContextMock(); + const engine2 = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine2.setVolume(-1); + engine2.start(); + expect(createdGains[0].gain.value).toBeCloseTo(0, 10); + }); + + it("coerces non-finite values (NaN, Infinity, -Infinity) to 0", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.setVolume(NaN); + engine.start(); + expect(createdGains[0].gain.value).toBe(0); + + installAudioContextMock(); + const engine2 = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine2.setVolume(Infinity); + engine2.start(); + expect(createdGains[0].gain.value).toBe(0); + + installAudioContextMock(); + const engine3 = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine3.setVolume(-Infinity); + engine3.start(); + expect(createdGains[0].gain.value).toBe(0); + }); + + it("anchors the ramp with setValueAtTime(currentValue, now) before linearRampToValueAtTime(~15 ms) when running", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.start(); + const masterGain = createdGains[0]; + const startValue = masterGain.gain.value; // should be the default 0.8² = 0.64 + mockContext.currentTime = 1.0; + + engine.setVolume(0.6); + + // Anchor comes first: setValueAtTime with the current gain value at `now`. + expect(masterGain.gain.setValueAtTime).toHaveBeenCalledTimes(1); + const [anchorValue, anchorTime] = ( + masterGain.gain.setValueAtTime as jest.Mock + ).mock.calls[0]; + expect(anchorValue).toBeCloseTo(startValue, 10); + expect(anchorTime).toBeCloseTo(1.0, 4); + + // Then the ramp to v² at now + 15 ms. + expect(masterGain.gain.linearRampToValueAtTime).toHaveBeenCalledTimes(1); + const [target, atTime] = ( + masterGain.gain.linearRampToValueAtTime as jest.Mock + ).mock.calls[0]; + expect(target).toBeCloseTo(0.36, 10); // 0.6² + expect(atTime).toBeCloseTo(1.015, 4); // currentTime + MASTER_RAMP_TIME (0.015) + }); + + it("does NOT ramp or anchor when not running; only caches the value for next start()", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.setVolume(0.3); + // No start() yet — no masterGain exists + engine.start(); + const masterGain = createdGains[0]; + expect(masterGain.gain.setValueAtTime).not.toHaveBeenCalled(); + expect(masterGain.gain.linearRampToValueAtTime).not.toHaveBeenCalled(); + expect(masterGain.gain.value).toBeCloseTo(0.09, 10); // 0.3² + }); +}); + +describe("MetronomeEngine — per-click attack envelope (accent / unaccented ratio)", () => { + beforeEach(() => { + installAudioContextMock(); + }); + + it("accented click ramps 0 → 1.0 over CLICK_ATTACK_TIME (3 ms) before oscillator.start", () => { + const engine = new MetronomeEngine({ bpm: 120, beatsPerMeasure: 4 }); + engine.start(); + // createdGains[0] = masterGain, [1] = first per-click envelope. + // currentBeat starts at 0 → isAccent true on first click. + const firstClickEnvelope = createdGains[1]; + + // Attack starts at 0 at the click time, then ramps linearly to peak 1.0 + // at time + 0.003 s. + expect(firstClickEnvelope.gain.setValueAtTime).toHaveBeenCalledWith(0, expect.any(Number)); + const [, rampValue, rampTime] = [ + null, + ...(firstClickEnvelope.gain.linearRampToValueAtTime as jest.Mock).mock.calls[0], + ]; + expect(rampValue).toBeCloseTo(1.0, 10); + + const setValueArgs = (firstClickEnvelope.gain.setValueAtTime as jest.Mock).mock.calls[0]; + const startTime = setValueArgs[1]; + expect(rampTime).toBeCloseTo(startTime + 0.003, 6); + + // And the attack ramp is scheduled BEFORE the oscillator starts — this is + // the anti-pop invariant. Jest tracks invocation order across all mocks. + const firstOsc = createdOscillators[0]; + const setValueOrder = (firstClickEnvelope.gain.setValueAtTime as jest.Mock).mock.invocationCallOrder[0]; + const linearOrder = (firstClickEnvelope.gain.linearRampToValueAtTime as jest.Mock).mock.invocationCallOrder[0]; + const oscStartOrder = (firstOsc.start as jest.Mock).mock.invocationCallOrder[0]; + expect(setValueOrder).toBeLessThan(oscStartOrder); + expect(linearOrder).toBeLessThan(oscStartOrder); + }); + + it("unaccented click ramps 0 → 0.75 (retuned from 0.5) over CLICK_ATTACK_TIME", () => { + // At BPM 1200 the click interval is 0.05 s. The engine's SCHEDULE_AHEAD_TIME + // is 0.1 s, so the while-loop inside schedule() queues two clicks on the + // first call: beat 0 (accented) and beat 1 (unaccented). + // createdGains[0] = masterGain, [1] = accent envelope, [2] = unaccent envelope. + const engine = new MetronomeEngine({ bpm: 1200, beatsPerMeasure: 2 }); + engine.start(); + // Preflight: if SCHEDULE_AHEAD_TIME ever drops below the 0.05 s interval, + // only one click will schedule and createdGains[2] would be undefined. + // Fail loudly in that case rather than silently passing / crashing. + expect(createdGains.length).toBeGreaterThanOrEqual(3); + const unaccentEnvelope = createdGains[2]; + const rampCall = (unaccentEnvelope.gain.linearRampToValueAtTime as jest.Mock).mock.calls[0]; + expect(rampCall[0]).toBeCloseTo(0.75, 10); + }); +}); diff --git a/frontend/next-app/src/lib/audio/metronome-engine.ts b/frontend/next-app/src/lib/audio/metronome-engine.ts index 84da1ae..ac775d2 100644 --- a/frontend/next-app/src/lib/audio/metronome-engine.ts +++ b/frontend/next-app/src/lib/audio/metronome-engine.ts @@ -10,6 +10,8 @@ export class MetronomeEngine { private nextNoteTime: number = 0; private currentBeat: number = 0; private isPlaying: boolean = false; + private masterGain: GainNode | null = null; + private masterVolume: number = 0.8; private bpm: number; private beatsPerMeasure: number; @@ -17,6 +19,8 @@ export class MetronomeEngine { private readonly SCHEDULE_AHEAD_TIME = 0.1; private readonly LOOKAHEAD = 25; + private readonly MASTER_RAMP_TIME = 0.015; // 15 ms — fast enough to feel instant while dragging the volume slider, slow enough to prevent audible zipper noise from direct gain.value writes. + private readonly CLICK_ATTACK_TIME = 0.003; // 3 ms — per-click attack ramp. Imperceptibly short as a volume change, but long enough to kill the DC-offset pop that hard-stepping from 0 to peak produces at osc.start(time). constructor(options: MetronomeOptions) { this.bpm = options.bpm; @@ -27,6 +31,9 @@ export class MetronomeEngine { start(): void { if (this.isPlaying) return; this.audioContext = new AudioContext(); + this.masterGain = this.audioContext.createGain(); + this.masterGain.gain.value = this.masterVolume * this.masterVolume; + this.masterGain.connect(this.audioContext.destination); this.isPlaying = true; this.currentBeat = 0; this.nextNoteTime = this.audioContext.currentTime; @@ -39,6 +46,10 @@ export class MetronomeEngine { clearTimeout(this.timerId); this.timerId = null; } + if (this.masterGain) { + this.masterGain.disconnect(); + this.masterGain = null; + } if (this.audioContext) { this.audioContext.close(); this.audioContext = null; @@ -58,6 +69,24 @@ export class MetronomeEngine { this.onBeat = callback; } + setVolume(v: number): void { + // Defend against NaN/Infinity from hand-edited localStorage or caller bugs — + // AudioParam.gain.value = NaN throws / produces undefined behavior depending on browser. + const safe = Number.isFinite(v) ? v : 0; + const clamped = Math.min(1, Math.max(0, safe)); + this.masterVolume = clamped; + if (this.audioContext && this.masterGain) { + const target = clamped * clamped; + const now = this.audioContext.currentTime; + // Anchor the ramp with an explicit setValueAtTime. linearRampToValueAtTime ramps from + // the value of the previous scheduled event — without this anchor, a bare `.gain.value = v` + // write (done in start()) isn't an event-list entry, and the ramp start-time is + // implementation-defined (historically buggy on older Safari). + this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, now); + this.masterGain.gain.linearRampToValueAtTime(target, now + this.MASTER_RAMP_TIME); + } + } + private schedule(): void { if (!this.isPlaying || !this.audioContext) return; @@ -78,13 +107,27 @@ export class MetronomeEngine { const gain = this.audioContext.createGain(); osc.connect(gain); - gain.connect(this.audioContext.destination); + // masterGain is non-null whenever isPlaying is true (start() assigns it before + // setting isPlaying, and stop() clears isPlaying before nulling it). The ?? + // fallback exists purely to satisfy the type checker, which can't prove the + // invariant through the setTimeout → schedule → playClick indirection. + gain.connect(this.masterGain ?? this.audioContext.destination); osc.frequency.value = isAccent ? 1000 : 800; - gain.gain.value = isAccent ? 1.0 : 0.5; + // Base per-click gain. Accent 1.0, unaccent 0.75 — a ~-2.5 dB gap so the + // downbeat still reads as the accent but non-downbeats aren't "too quiet + // to hear" (see issue #42). Master volume scales both via masterGain. + const peak = isAccent ? 1.0 : 0.75; + + // Attack: ramp 0 → peak over CLICK_ATTACK_TIME (3 ms). Scheduled BEFORE + // osc.start so the oscillator's first sample lands inside a smooth envelope + // instead of a hard step — eliminates the DC-offset click/pop artifact. + gain.gain.setValueAtTime(0, time); + gain.gain.linearRampToValueAtTime(peak, time + this.CLICK_ATTACK_TIME); + // Decay: exponential down to ~silence over 50 ms total click length. + gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); osc.start(time); - gain.gain.exponentialRampToValueAtTime(0.001, time + 0.05); osc.stop(time + 0.05); } } diff --git a/frontend/next-app/src/lib/practice-session-store.test.ts b/frontend/next-app/src/lib/practice-session-store.test.ts new file mode 100644 index 0000000..14c2a6c --- /dev/null +++ b/frontend/next-app/src/lib/practice-session-store.test.ts @@ -0,0 +1,81 @@ +/** + * @jest-environment jsdom + */ +import { + getMetronomeVolume, + saveMetronomeVolume, +} from "./practice-session-store"; + +// jest.setup.ts replaces global.localStorage with jest.fn() stubs that don't +// actually store. Install a Map-backed replacement so round-trip tests work. +const installRealLocalStorage = () => { + const store = new Map(); + const mock: Storage = { + get length() { + return store.size; + }, + key: (i) => Array.from(store.keys())[i] ?? null, + getItem: (k) => (store.has(k) ? (store.get(k) as string) : null), + setItem: (k, v) => { + store.set(k, String(v)); + }, + removeItem: (k) => { + store.delete(k); + }, + clear: () => { + store.clear(); + }, + }; + Object.defineProperty(window, "localStorage", { + value: mock, + configurable: true, + writable: true, + }); + return store; +}; + +describe("metronome volume persistence", () => { + beforeEach(() => { + installRealLocalStorage(); + }); + + it("round-trips a value saved and then read", () => { + saveMetronomeVolume(0.6); + expect(getMetronomeVolume()).toBeCloseTo(0.6, 10); + }); + + it("returns null when nothing has been saved", () => { + expect(getMetronomeVolume()).toBeNull(); + }); + + it("clamps on save: values > 1 store as 1", () => { + saveMetronomeVolume(2); + expect(getMetronomeVolume()).toBe(1); + }); + + it("clamps on save: negative values store as 0", () => { + saveMetronomeVolume(-0.5); + expect(getMetronomeVolume()).toBe(0); + }); + + it("clamps on read: hand-edited out-of-range values are clamped", () => { + window.localStorage.setItem( + "practice:metronome-volume", + JSON.stringify({ volume: 5, updatedAt: "x" }), + ); + expect(getMetronomeVolume()).toBe(1); + }); + + it("returns null when stored JSON is corrupt", () => { + window.localStorage.setItem("practice:metronome-volume", "{ not json"); + expect(getMetronomeVolume()).toBeNull(); + }); + + it("returns null when stored object is missing the volume field", () => { + window.localStorage.setItem( + "practice:metronome-volume", + JSON.stringify({ updatedAt: "x" }), + ); + expect(getMetronomeVolume()).toBeNull(); + }); +}); diff --git a/frontend/next-app/src/lib/practice-session-store.ts b/frontend/next-app/src/lib/practice-session-store.ts index e9836d9..a557aeb 100644 --- a/frontend/next-app/src/lib/practice-session-store.ts +++ b/frontend/next-app/src/lib/practice-session-store.ts @@ -109,6 +109,30 @@ export const clearStoredRecommendation = () => { removeItem(RECOMMENDATION_KEY); }; +// --- Metronome volume (global, device-scoped) --- + +const METRONOME_VOLUME_KEY = "practice:metronome-volume"; + +export interface MetronomeVolumeRecord { + volume: number; // 0..1 + updatedAt: string; +} + +const clamp01 = (n: number): number => Math.min(1, Math.max(0, n)); + +export const getMetronomeVolume = (): number | null => { + const record = readJson(METRONOME_VOLUME_KEY); + if (!record || typeof record.volume !== "number") return null; + return clamp01(record.volume); +}; + +export const saveMetronomeVolume = (volume: number): void => { + writeJson(METRONOME_VOLUME_KEY, { + volume: clamp01(volume), + updatedAt: new Date().toISOString(), + } satisfies MetronomeVolumeRecord); +}; + // --- Per-instrument project persistence (Launch Pad) --- export const INSTRUMENTS = ["Guitar", "Bass", "Drums", "Keys"] as const;