Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jest.mock('@/lib/audio/metronome-engine', () => ({
setBpm: jest.fn(),
setBeatsPerMeasure: jest.fn(),
setOnBeat: jest.fn(),
setVolume: jest.fn(),
})),
}));

Expand All @@ -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,
}));

Expand Down
23 changes: 22 additions & 1 deletion frontend/next-app/src/app/practice-timer/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,9 @@ import { AnimatePresence } from "framer-motion";
import {
clearStoredPracticeSetup,
clearStoredSessionSnapshot,
getMetronomeVolume,
getStoredPracticeSetup,
saveMetronomeVolume,
saveStoredPracticeSetup,
saveStoredSessionSnapshot,
getProject,
Expand Down Expand Up @@ -77,6 +79,18 @@ function PracticeTimerContent() {
const [currentBeat, setCurrentBeat] = useState(-1);
const [beatsPerMeasure, setBeatsPerMeasure] = useState(4);
const [, setTapTimes] = useState<number[]>([]);
const [metronomeVolume, setMetronomeVolumeState] = useState<number>(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);
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand Down Expand Up @@ -1001,6 +1020,8 @@ function PracticeTimerContent() {
onBeatsPerMeasureChange={setBeatsPerMeasure}
onToggle={handleMetronomeToggle}
onTapTempo={handleTapTempo}
volume={metronomeVolume}
onVolumeChange={handleMetronomeVolumeChange}
/>
</div>
<div className="space-y-4">
Expand Down
62 changes: 62 additions & 0 deletions frontend/next-app/src/components/studio/MetronomeWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<MetronomeWidget {...baseProps} volume={0.3} />);
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(
<MetronomeWidget
{...baseProps}
volume={0.5}
onVolumeChange={onVolumeChange}
/>,
);
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(<MetronomeWidget {...baseProps} volume={0} />);
expect(screen.getByTestId("volume-icon-muted")).toBeInTheDocument();
});

it("shows the 'low' icon when 0 < volume < 0.5", () => {
render(<MetronomeWidget {...baseProps} volume={0.3} />);
expect(screen.getByTestId("volume-icon-low")).toBeInTheDocument();
});

it("shows the 'high' icon when volume >= 0.5", () => {
render(<MetronomeWidget {...baseProps} volume={0.8} />);
expect(screen.getByTestId("volume-icon-high")).toBeInTheDocument();
});
});
19 changes: 19 additions & 0 deletions frontend/next-app/src/components/studio/MetronomeWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand All @@ -23,6 +24,8 @@ interface MetronomeWidgetProps {
onBeatsPerMeasureChange: (beats: number) => void;
onToggle: () => void;
onTapTempo: () => void;
volume: number;
onVolumeChange: (volume: number) => void;
}

export default function MetronomeWidget({
Expand All @@ -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)));
Expand Down Expand Up @@ -79,6 +84,20 @@ export default function MetronomeWidget({
</Button>
</div>

<div className="flex items-center gap-3">
<VolumeIcon volume={volume} className="text-muted-foreground shrink-0" />
<input
type="range"
min={0}
max={1}
step={0.01}
value={volume}
onChange={(e) => 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"
/>
</div>

<div className="flex justify-center gap-2 py-2">
{Array.from({ length: beatsPerMeasure }).map((_, i) => {
const isActiveBeat = currentBeat === i;
Expand Down
44 changes: 44 additions & 0 deletions frontend/next-app/src/components/studio/VolumeIcon.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<SpeakerX
size={size}
className={className}
data-testid="volume-icon-muted"
aria-hidden
/>
);
}
if (volume < 0.5) {
return (
<SpeakerLow
size={size}
className={className}
data-testid="volume-icon-low"
aria-hidden
/>
);
}
return (
<SpeakerHigh
size={size}
className={className}
data-testid="volume-icon-high"
aria-hidden
/>
);
}
Loading
Loading