diff --git a/e2e/listen.spec.ts b/e2e/listen.spec.ts new file mode 100644 index 0000000..36233b0 --- /dev/null +++ b/e2e/listen.spec.ts @@ -0,0 +1,123 @@ +import { test, expect, type Page } from '@playwright/test'; + +type OK = { useApp: { getState: () => { settingsLoaded: boolean; updateSettings: (p: object) => void } } }; + +/** Mocked Web MIDI: one keyboard with an input and an output that records what it is sent. */ +const MOCK = () => { + const input = { id: 'in1', name: 'CASIO USB-MIDI', manufacturer: 'Casio', state: 'connected', type: 'input', onmidimessage: null }; + const sent: number[][] = []; + const output = { id: 'out1', name: 'CASIO USB-MIDI', manufacturer: 'Casio', state: 'connected', type: 'output', send: (b: number[]) => sent.push([...b]) }; + const access = { inputs: new Map([[input.id, input]]), outputs: new Map([[output.id, output]]), onstatechange: null, sysexEnabled: false }; + (navigator as unknown as { requestMIDIAccess: () => Promise }).requestMIDIAccess = async () => access; + (window as unknown as { __midiSent: number[][] }).__midiSent = sent; +}; + +async function openOde(page: Page, settings: object) { + await page.goto('/'); + await page.waitForFunction(() => (globalThis as unknown as { __openkeys: OK }).__openkeys.useApp.getState().settingsLoaded); + await page.evaluate((s) => (globalThis as unknown as { __openkeys: OK }).__openkeys.useApp.getState().updateSettings({ countInBars: 0, metronome: false, onboarded: true, ...s }), settings); + await page.getByRole('button', { name: /Library/ }).first().click(); + await page.locator('.card', { hasText: 'Ode to Joy' }).getByRole('button', { name: 'Right hand' }).click(); + await expect(page.getByRole('button', { name: /Start/ })).toBeVisible(); +} + +test('listen: options appear only in listen mode and the keys play along', async ({ page }) => { + await openOde(page, { inputSource: 'virtual' }); + const animate = page.getByRole('switch', { name: 'Animate keys' }); + const menu = page.getByRole('button', { name: 'Listen options' }); + await expect(menu).toHaveCount(0); + await page.getByRole('button', { name: 'Listen', exact: true }).click(); + await menu.click(); + await expect(animate).toHaveAttribute('aria-checked', 'true'); + // No MIDI keyboard: no speaker option. + await expect(page.getByRole('switch', { name: 'Keyboard speakers' })).toHaveCount(0); + + // Count every key going down and coming back up (the gaps between notes are too short to sample). + await page.evaluate(() => { + const moves = { down: 0, up: 0 }; + (window as unknown as { __keyMoves: typeof moves }).__keyMoves = moves; + new MutationObserver((records) => { + for (const r of records) { + const el = r.target as HTMLElement; + const lit = (c: string[]) => c.some((x) => x === 'down' || x === 'play-R' || x === 'play-L'); + const was = lit((r.oldValue ?? '').split(' ')); + const is = lit([...el.classList]); + if (!was && is) moves.down++; + if (was && !is) moves.up++; + } + }).observe(document.querySelector('.keyboard')!, { subtree: true, attributeFilter: ['class'], attributeOldValue: true }); + }); + await page.getByRole('button', { name: /Start/ }).click(); + const moves = () => page.evaluate(() => (window as unknown as { __keyMoves: { down: number; up: number } }).__keyMoves); + await expect.poll(async () => (await moves()).up, { timeout: 10_000 }).toBeGreaterThanOrEqual(3); + expect((await moves()).down).toBeGreaterThanOrEqual(3); + + // Hand colours (on by default): Ode to Joy's right hand lights blue, never the neutral pressed look. + await expect(page.locator('.keyboard .key.play-R').first()).toBeVisible(); + expect(await page.locator('.keyboard .key.down, .keyboard .key.play-L').count()).toBe(0); + + // Clicking Start closed the menu; open it again while the piece plays. + await expect(animate).toHaveCount(0); + await menu.click(); + const colours = page.getByRole('switch', { name: 'Hand colours' }); + await colours.click(); + await expect(colours).toHaveAttribute('aria-checked', 'false'); + await expect(page.locator('.keyboard .key.down').first()).toBeVisible(); + await expect(page.locator('.keyboard .key.play-R')).toHaveCount(0); + + await animate.click(); + await expect(colours).toBeDisabled(); + await expect(animate).toHaveAttribute('aria-checked', 'false'); + const lit = page.locator('.keyboard .key.down, .keyboard .key.play-R, .keyboard .key.play-L'); + await expect(lit).toHaveCount(0); + await page.waitForTimeout(1500); + await expect(lit).toHaveCount(0); + await page.getByRole('button', { name: /Stop/ }).click(); +}); + +test('listen: plays through the MIDI keyboard speakers when one is connected', async ({ page }) => { + await page.addInitScript(MOCK); + await openOde(page, { inputSource: 'midi' }); + await page.getByRole('button', { name: 'Connect MIDI keyboard' }).click(); + await expect(page.locator('.meter')).toContainText('CASIO USB-MIDI'); + await page.getByRole('button', { name: 'Listen', exact: true }).click(); + await page.getByRole('button', { name: 'Listen options' }).click(); + const speakers = page.getByRole('switch', { name: 'Keyboard speakers' }); + await expect(speakers).toHaveAttribute('aria-checked', 'false'); + await speakers.click(); + await expect(speakers).toHaveAttribute('aria-checked', 'true'); + await page.keyboard.press('Escape'); + await expect(speakers).toHaveCount(0); + await page.getByRole('button', { name: /Start/ }).click(); + // E4 (64) is the first note of Ode to Joy. + await expect.poll(() => page.evaluate(() => (window as unknown as { __midiSent: number[][] }).__midiSent.some((b) => b[0] === 0x90 && b[1] === 64))).toBe(true); + await expect(page.locator('.keyboard .key.play-R').first()).toBeVisible(); + await page.getByRole('button', { name: /Stop/ }).click(); +}); + +test('the practice bar switches the input between computer keys, MIDI and microphone', async ({ page }) => { + await openOde(page, { inputSource: 'virtual' }); + const input = page.getByRole('radiogroup', { name: 'Input' }); + const meter = page.locator('.meter .detail'); + await expect(input.getByRole('radio', { name: 'Computer keys' })).toHaveAttribute('aria-checked', 'true'); + await expect(meter).toHaveText('Computer keys / on-screen piano'); + + await input.getByRole('radio', { name: 'MIDI keyboard' }).click(); + await expect(input.getByRole('radio', { name: 'MIDI keyboard' })).toHaveAttribute('aria-checked', 'true'); + await expect(page.getByRole('button', { name: 'Connect MIDI keyboard' })).toBeVisible(); + await expect(meter).not.toHaveText('Computer keys / on-screen piano'); + + await input.getByRole('radio', { name: 'Microphone' }).click(); + await expect(page.getByRole('button', { name: 'Turn on the microphone' })).toBeVisible(); + + await input.getByRole('radio', { name: 'Computer keys' }).click(); + await expect(meter).toHaveText('Computer keys / on-screen piano'); + await expect(page.getByRole('button', { name: 'Turn on the microphone' })).toHaveCount(0); + + // Locked while a take runs. + await page.getByRole('button', { name: 'Listen', exact: true }).click(); + await page.getByRole('button', { name: /Start/ }).click(); + await expect(input.getByRole('radio', { name: 'Microphone' })).toBeDisabled(); + await page.getByRole('button', { name: /Stop/ }).click(); + await expect(input.getByRole('radio', { name: 'Microphone' })).toBeEnabled(); +}); diff --git a/src/core/playback/player.ts b/src/core/playback/player.ts index 9f52e7c..76f1669 100644 --- a/src/core/playback/player.ts +++ b/src/core/playback/player.ts @@ -1,9 +1,10 @@ -import type { HandSelection, Score, ScoreNote } from '../types'; +import type { Hand, HandSelection, Score, ScoreNote } from '../types'; import { TempoMap, metronomeBeats, scoreEndBeat, timeSignatureAt, measureLengthBeats } from '../score/tempo'; import { Emitter } from '../emitter'; import { Timeline } from './timeline'; import type { ClickSink, NoteSink, ScheduledAudioLog } from './sink'; import { soundingEndBeat } from './pedal'; +import { tiedLengthBeats } from '../score/events'; export interface PlayerConfig { /** 0.25 - 1.5 */ @@ -49,6 +50,11 @@ export class Player { readonly ended = new Emitter(); /** Visual metronome pulse (fires at schedule time with the click's context time). */ readonly pulse = new Emitter<{ time: number; accent: boolean; sub: boolean; countIn: boolean }>(); + /** + * Each score note as it is scheduled: context times of the key going down and coming back up + * (the written length including ties, not the pedalled sound). Drives the listen-mode keys. + */ + readonly scheduled = new Emitter<{ midi: number; hand: Hand; time: number; upTime: number }>(); state: PlayerState = 'stopped'; config: PlayerConfig = { ...DEFAULT_PLAYER_CONFIG }; @@ -208,6 +214,10 @@ export class Player { const vel = n.velocity ?? this.config.defaultVelocity; this.sink.play(n.midi, vel, t, dur); this.log?.addNote(n.midi, t, t + dur); + if (this.scheduled.size) { + const upBeat = Math.min(this.segEnd, n.startBeat + tiedLengthBeats(this.score.notes, n)); + this.scheduled.emit({ midi: n.midi, hand: n.hand, time: t, upTime: Math.max(t + 0.05, this.segTime(upBeat)) }); + } } // Clicks. while (this.clickIdx < this.clicks.length && this.clicks[this.clickIdx].beat < horizonBeat) { diff --git a/src/core/settings.ts b/src/core/settings.ts index d60fd44..bd96274 100644 --- a/src/core/settings.ts +++ b/src/core/settings.ts @@ -40,6 +40,10 @@ export interface Settings { /** In mic mode, allow app audio through the speakers anyway (default muted). */ micAllowSpeakerPlayback: boolean; errorSound: boolean; + /** Listen mode: the on-screen keys go down and up with the notes the app plays. */ + listenKeyAnimation: boolean; + /** Listen mode: animated keys take their hand's colour (blue right, purple left). */ + listenHandColours: boolean; splitPoint: number; detector: DetectorParams; debug: boolean; @@ -76,6 +80,8 @@ export const DEFAULT_SETTINGS: Settings = { accompaniment: true, micAllowSpeakerPlayback: false, errorSound: false, + listenKeyAnimation: true, + listenHandColours: true, splitPoint: 60, detector: DEFAULT_DETECTOR_PARAMS, debug: false, diff --git a/src/ui/components/PianoKeyboard.tsx b/src/ui/components/PianoKeyboard.tsx index 1e714af..f979c74 100644 --- a/src/ui/components/PianoKeyboard.tsx +++ b/src/ui/components/PianoKeyboard.tsx @@ -60,14 +60,18 @@ export function PianoKeyboard({ low, high, height = 120, labels = 'c', keyHintsB useEffect(() => { const apply = () => { for (const [midi, el] of els.current) { - const mark = liveKeys.pressed.get(midi); + // Keys the app plays (listen mode) look pressed, in their hand's colour when it has one. + const played = liveKeys.pressed.has(midi) ? undefined : liveKeys.playingHand(midi); + const mark = liveKeys.pressed.get(midi) ?? (played === null ? 'down' : undefined); const up = liveKeys.upcoming.get(midi); el.classList.toggle('down', mark === 'down'); el.classList.toggle('hit', mark === 'hit'); el.classList.toggle('wrong', mark === 'wrong'); el.classList.toggle('up-R', up === 'R' || up === 'unknown'); el.classList.toggle('up-L', up === 'L'); - el.setAttribute('aria-pressed', mark ? 'true' : 'false'); + el.classList.toggle('play-R', played === 'R' || played === 'unknown'); + el.classList.toggle('play-L', played === 'L'); + el.setAttribute('aria-pressed', mark || played ? 'true' : 'false'); } }; apply(); diff --git a/src/ui/components/SourceSwitch.tsx b/src/ui/components/SourceSwitch.tsx new file mode 100644 index 0000000..ef724ad --- /dev/null +++ b/src/ui/components/SourceSwitch.tsx @@ -0,0 +1,33 @@ +import type { InputSource } from '../../core/types'; +import { useApp } from '../store'; +import { runtime } from '../runtime'; +import { Icon } from './Icon'; + +const SOURCES: { id: InputSource; label: string; icon: 'keyboard' | 'piano' | 'mic' }[] = [ + { id: 'virtual', label: 'Computer keys', icon: 'keyboard' }, + { id: 'midi', label: 'MIDI keyboard', icon: 'piano' }, + { id: 'mic', label: 'Microphone', icon: 'mic' }, +]; + +/** Compact input picker beside the input meter: computer keys, MIDI keyboard or microphone. */ +export function SourceSwitch({ disabled = false }: { disabled?: boolean }) { + const source = useApp((s) => s.settings.inputSource); + return ( +
+ {SOURCES.map((s) => ( + + ))} +
+ ); +} diff --git a/src/ui/live.ts b/src/ui/live.ts index 23c20a9..f3abc65 100644 --- a/src/ui/live.ts +++ b/src/ui/live.ts @@ -11,6 +11,11 @@ class LiveKeys { pressed = new Map(); /** Upcoming keys to play (outlined), with hand. */ upcoming = new Map(); + /** + * Keys the app is playing in listen mode: midi -> the notes holding it down (token -> hand, + * or null for the neutral pressed look). + */ + playing = new Map>(); private listeners = new Set<() => void>(); private scheduled = false; private flashTimers = new Map(); @@ -68,6 +73,36 @@ class LiveKeys { ); } + /** Listen mode: a note the app plays holds its key down (tokens keep overlapping notes apart). */ + playOn(midi: number, token: number, hand: Hand | null = null) { + const held = this.playing.get(midi); + if (held) held.set(token, hand); + else this.playing.set(midi, new Map([[token, hand]])); + this.changed(); + } + + playOff(midi: number, token: number) { + const held = this.playing.get(midi); + if (!held?.delete(token)) return; + if (!held.size) this.playing.delete(midi); + this.changed(); + } + + /** Hand colour of a key the app is playing (the latest note on it); null = neutral; undefined = not playing. */ + playingHand(midi: number): Hand | null | undefined { + const held = this.playing.get(midi); + if (!held) return undefined; + let hand: Hand | null = null; + for (const h of held.values()) hand = h; + return hand; + } + + clearPlaying() { + if (!this.playing.size) return; + this.playing.clear(); + this.changed(); + } + setUpcoming(next: Map) { let same = next.size === this.upcoming.size; if (same) for (const [k, v] of next) if (this.upcoming.get(k) !== v) same = false; @@ -78,6 +113,7 @@ class LiveKeys { clear() { this.pressed.clear(); + this.playing.clear(); this.upcoming = new Map(); this.changed(); } diff --git a/src/ui/practice/ListenOptions.tsx b/src/ui/practice/ListenOptions.tsx new file mode 100644 index 0000000..49410f1 --- /dev/null +++ b/src/ui/practice/ListenOptions.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef, useState } from 'react'; +import { useApp } from '../store'; +import { runtime } from '../runtime'; +import { liveKeys } from '../live'; +import { Icon } from '../components/Icon'; + +/** Names of the connected MIDI outputs (empty until MIDI is in use). */ +function useMidiOutputs(): string[] { + const [names, setNames] = useState([]); + useEffect(() => { + let offPorts = () => {}; + const attach = () => { + offPorts(); + const midi = runtime.midi; + if (!midi) return; + const read = () => setNames(midi.outputs().filter((o) => o.state === 'connected').map((o) => o.name)); + read(); + offPorts = midi.ports.on(read); + }; + attach(); + const offStatus = runtime.midiStatus.on(attach); + return () => { + offPorts(); + offStatus(); + }; + }, []); + return names; +} + +function Switch({ on, label, title, disabled, onChange }: { on: boolean; label: string; title: string; disabled?: boolean; onChange: (on: boolean) => void }) { + return ( + + ); +} + +/** + * Listen-mode options behind a small settings button; the menu opens upwards over the keyboard. + * Each option is a row with a switch, so more can be added as rows. + */ +export function ListenOptions() { + const settings = useApp((s) => s.settings); + const update = useApp((s) => s.updateSettings); + const outputs = useMidiOutputs(); + const speakers = outputs.length > 0; + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + if (!open) return; + const down = (e: PointerEvent) => { + if (!ref.current?.contains(e.target as Node)) setOpen(false); + }; + const key = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + window.addEventListener('pointerdown', down); + window.addEventListener('keydown', key); + return () => { + window.removeEventListener('pointerdown', down); + window.removeEventListener('keydown', key); + }; + }, [open]); + + return ( +
+ + {open && ( +
+
Listen options
+ {speakers && ( + + update({ + midiOutPlayback: on, + // The remembered output is gone: use the one that is connected. + ...(on && !(settings.midiOutputName && outputs.includes(settings.midiOutputName)) ? { midiOutputName: outputs[0] } : {}), + }) + } + /> + )} + { + update({ listenKeyAnimation: on }); + if (!on) liveKeys.clearPlaying(); + }} + /> + update({ listenHandColours: on })} + /> +
+ )} +
+ ); +} diff --git a/src/ui/practice/PracticeScreen.tsx b/src/ui/practice/PracticeScreen.tsx index 582916b..975012c 100644 --- a/src/ui/practice/PracticeScreen.tsx +++ b/src/ui/practice/PracticeScreen.tsx @@ -21,6 +21,8 @@ import { TakeReplay } from './TakeReplay'; import { ShortcutsHelp } from '../components/ShortcutsHelp'; import { kvSet } from '../../core/progress/db'; import { Icon } from '../components/Icon'; +import { ListenOptions } from './ListenOptions'; +import { SourceSwitch } from '../components/SourceSwitch'; const MODES: { id: PracticeMode; label: string; hint: string }[] = [ { id: 'listen', label: 'Listen', hint: 'Hear the piece; the cursor follows.' }, @@ -376,12 +378,14 @@ function Practice({ score }: { score: Score }) {
{!compact && } +
{line || (running ? '' : range ? modeHint : `${modeHint} Drag across bars to loop.`)}
{streak >= 3 && {streak} } + {mode === 'listen' && } {score.title} {score.variant ? ` ยท ${score.variant}` : ''} diff --git a/src/ui/practice/controller.ts b/src/ui/practice/controller.ts index d91169b..6c59d5c 100644 --- a/src/ui/practice/controller.ts +++ b/src/ui/practice/controller.ts @@ -36,6 +36,9 @@ class PracticeController { readonly stateChange = new Emitter(); private unsubs: (() => void)[] = []; private raf = 0; + /** Listen-mode key animation timers. */ + private keyTimers = new Set(); + private keyToken = 0; private score: Score | null = null; private idleBeat = 0; private opts: TakeOptions | null = null; @@ -207,6 +210,7 @@ class PracticeController { practiceLive.changed(); }), ); + if (opts.mode === 'listen') this.unsubs.push(engine.player.scheduled.on((n) => this.animateKey(n.midi, n.hand, n.time, n.upTime))); session.start(); practiceLive.running = true; this.stateChange.emit(session.state); @@ -221,6 +225,30 @@ class PracticeController { if (opts.mode !== 'listen') this.raf = requestAnimationFrame(loop); } + /** Listen mode: presses the on-screen key when the note is heard and lifts it when it ends. */ + private animateKey(midi: number, hand: Hand, time: number, upTime: number) { + const engine = runtime.engine; + if (!engine || !getSettings().listenKeyAnimation) return; + const token = ++this.keyToken; + const now = performance.now(); + const down = Math.max(0, engine.audioToPerf(time) - now); + // Lift a little early so a repeated note visibly strikes again. + const len = (upTime - time) * 1000; + const up = down + Math.max(40, len - Math.min(60, len * 0.25)); + const at = (ms: number, fn: () => void) => { + const id = window.setTimeout(() => { + this.keyTimers.delete(id); + fn(); + }, ms); + this.keyTimers.add(id); + }; + at(down, () => { + const s = getSettings(); + if (s.listenKeyAnimation) liveKeys.playOn(midi, token, s.listenHandColours ? hand : null); + }); + at(up, () => liveKeys.playOff(midi, token)); + } + private onFeedback(fb: FollowerFeedback, score: Score) { const s = getSettings(); switch (fb.type) { @@ -297,6 +325,9 @@ class PracticeController { private cleanup() { this.detector = null; cancelAnimationFrame(this.raf); + for (const id of this.keyTimers) window.clearTimeout(id); + this.keyTimers.clear(); + liveKeys.clearPlaying(); for (const u of this.unsubs) u(); this.unsubs = []; const s = this.session; diff --git a/src/ui/runtime.ts b/src/ui/runtime.ts index d7b3df3..5f788b7 100644 --- a/src/ui/runtime.ts +++ b/src/ui/runtime.ts @@ -201,6 +201,8 @@ class Runtime { if (!isInput) return; useApp.getState().toast(connected ? `${name} connected` : `${name} disconnected; plug it back in to continue`, connected ? 'good' : 'warn'); }); + // Keyboard plugged in or out: route playback to it, or back to the app piano. + midi.ports.on(() => this.applyMidiOut()); midi.deviceChanged.on((name) => { meter.set({ ...meter.value, source: 'midi', deviceName: name, active: !!name }); if (name && name !== getSettings().midiInputName) useApp.getState().updateSettings({ midiInputName: name }); @@ -214,6 +216,15 @@ class Runtime { return this.midi; } + /** Switches the judged input; the meter shows the new source straight away. */ + setSource(source: InputSource): void { + if (source !== 'mic') this.stopMic(); + useApp.getState().updateSettings({ inputSource: source }); + const device = source === 'midi' ? (this.midi?.current?.name ?? null) : null; + meter.set({ ...meter.value, source, deviceName: device, active: source === 'midi' && !!device, noteName: null, cents: null, confidence: 0, lastVelocity: null, levelDb: -100 }); + void this.reloadCalibration(); + } + /** Routes scheduled playback to the keyboard over MIDI out when enabled. */ applyMidiOut(): void { const eng = getEngine(); diff --git a/src/ui/styles.css b/src/ui/styles.css index f4a2aac..f119488 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -750,6 +750,20 @@ input[type='range'], background: var(--ok-red); color: var(--ok-white); } +/* Listen mode with hand colours: the key the app plays dips in its hand's colour. */ +.key.play-R, +.key.play-L { + transform: scaleY(0.98); + transition-duration: var(--duration-fast); +} +.key.play-R { + background: var(--hand-r); + color: var(--ok-white); +} +.key.play-L { + background: var(--hand-l); + color: var(--ok-ink); +} .key.middle-c::after { content: ''; position: absolute; @@ -868,7 +882,52 @@ input[type='range'], justify-content: center; } -.toolbar .tempo .pulse-dot { +.toolbar .tempo /* Input picker beside the meter: a soft-cloud pill track of round icon buttons; chosen = ink. */ +.source-switch { + display: inline-flex; + align-items: center; + flex: none; + padding: var(--space-xxs); + border-radius: var(--radius-button); + background: var(--surface-soft); +} + +.source-switch button { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--radius-full); + background: transparent; + color: var(--text-secondary); + cursor: pointer; + transition: + transform var(--duration-base) var(--ease-standard), + opacity var(--duration-base) var(--ease-standard); +} + +.source-switch button[aria-checked='true'] { + background: var(--color-primary); + color: var(--color-on-primary); +} + +.source-switch button:active:not(:disabled) { + transform: scale(var(--press-scale)); + opacity: var(--press-opacity); +} + +.source-switch button:disabled { + cursor: not-allowed; +} + +.source-switch button:disabled:not([aria-checked='true']) { + color: var(--text-tertiary); +} + +.pulse-dot { margin-right: var(--space-sm); } @@ -1049,6 +1108,115 @@ input[type='range'], text-overflow: ellipsis; } +/* + * Listen-mode options: a quiet settings button; its menu opens upwards as a square panel with + * one row per option. Switches are pill tracks; on = ink (selected = inversion). + */ +.listen-options { + position: relative; + display: inline-flex; +} + +.listen-options .icon-btn { + width: 32px; + height: 32px; + color: var(--text-secondary); +} + +.listen-options .icon-btn[aria-expanded='true'] { + color: var(--text-primary); +} + +.listen-menu { + position: absolute; + right: 0; + /* Clears the hairline above the keyboard panel so the menu reads as floating over the stage. */ + bottom: calc(100% + var(--space-lg)); + z-index: 30; + min-width: 240px; + display: flex; + flex-direction: column; + padding: var(--space-sm); + background: var(--surface-canvas); + border: 1px solid var(--border-hairline); +} + +.listen-menu-title { + padding: var(--space-xs) var(--space-md) var(--space-sm); + font-size: var(--type-caption-sm-size); + line-height: var(--type-caption-sm-lh); + color: var(--text-secondary); +} + +.listen-menu .switch { + justify-content: space-between; + width: 100%; + background: transparent; +} + +.switch { + display: inline-flex; + align-items: center; + gap: var(--space-sm); + min-height: 32px; + padding: var(--space-xs) var(--space-xs) var(--space-xs) var(--space-md); + border: 0; + border-radius: var(--radius-button); + background: var(--surface-soft); + color: var(--text-primary); + font-size: var(--type-button-sm-size); + line-height: var(--type-button-sm-lh); + font-weight: var(--weight-medium); + white-space: nowrap; + cursor: pointer; + transition: + transform var(--duration-base) var(--ease-standard), + opacity var(--duration-base) var(--ease-standard); +} + +.switch:disabled { + color: var(--text-tertiary); + cursor: not-allowed; +} + +.switch:disabled .switch-track { + opacity: 0.5; +} + +.switch:active:not(:disabled) { + transform: scale(var(--press-scale)); + opacity: var(--press-opacity); +} + +.switch-track { + position: relative; + flex: none; + width: 36px; + height: 22px; + border-radius: var(--radius-full); + background: var(--border-hairline); + transition: background-color var(--duration-base) var(--ease-standard); +} + +.switch-knob { + position: absolute; + top: 3px; + left: 3px; + width: 16px; + height: 16px; + border-radius: var(--radius-full); + background: var(--surface-canvas); + transition: transform var(--duration-base) var(--ease-standard); +} + +.switch[aria-checked='true'] .switch-track { + background: var(--color-primary); +} + +.switch[aria-checked='true'] .switch-knob { + transform: translateX(14px); +} + .results { position: absolute; inset: var(--space-lg); @@ -1289,7 +1457,9 @@ kbd { .key.hit, .key.wrong, .key.up-R, - .key.up-L { + .key.up-L, + .key.play-R, + .key.play-L { outline: 3px solid Highlight; } .btn.primary, diff --git a/tests/playback/player.test.ts b/tests/playback/player.test.ts index 77edd4d..915453f 100644 --- a/tests/playback/player.test.ts +++ b/tests/playback/player.test.ts @@ -93,6 +93,29 @@ describe('Player', () => { p.stop(); }); + it('reports key down and up times for each scheduled note (ties held, pedal ignored)', () => { + const { p, advance } = setup(); + const score = makeScore(); + score.notes.push({ id: 'e', midi: 64, startBeat: 8, durationBeats: 2, measure: 1, hand: 'R', tiedFromPrevious: true }); + score.measures.push({ index: 2, startBeat: 8, lengthBeats: 4 }); + score.pedal = [ + { beat: 0, down: true }, + { beat: 4, down: false }, + ]; + p.setScore(score); + p.configure({ countInBars: 0, tempoFactor: 1 }); + const keys: { midi: number; time: number; upTime: number }[] = []; + p.scheduled.on((k) => keys.push(k)); + const start = p.start(0); + advance(start + 7); + const c = keys.find((k) => k.midi === 60)!; + expect(c.time).toBeCloseTo(start); + expect(c.upTime).toBeCloseTo(start + 0.5); // one beat at 120 bpm, not held to the pedal lift + const e = keys.find((k) => k.midi === 64)!; + expect(e.upTime - e.time).toBeCloseTo(3); // 4 beats tied into 2 more + expect(keys.filter((k) => k.midi === 64)).toHaveLength(1); + }); + it('logs clicks for mic gating', () => { const { p, log, advance } = setup(); p.configure({ metronome: true, countInBars: 0 });