Phase 4: drone + T6/E-series + polish + deploy + README

Add drone synth + DronePlayer, wire T6 (mode over drone) and E1-E3 info pages
(E3 carries a drone). Screen Wake Lock while any click/drone plays. Vitest suite
for scheduler timing (no drift over 5 min) and music-layer correctness (triads,
CAGED, diatonic, fretboard). Deploy artifacts: public/_redirects, multi-stage
Dockerfile + nginx.conf (SPA fallback, immutable asset caching, gzip),
.dockerignore. README covering build, Cloudflare Pages / nginx / Docker deploy,
and the deep-link URL format. prefers-reduced-motion honored throughout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-22 00:36:34 +02:00
parent 365aac58a0
commit 083744203b
15 changed files with 706 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.git
.vite
*.log
+20
View File
@@ -0,0 +1,20 @@
# --- Build stage ---------------------------------------------------------------
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
# Base path baked at build time. Default '/' (dedicated domain).
# For a subpath deploy: docker build --build-arg VITE_BASE=/practice/ .
ARG VITE_BASE=/
ENV VITE_BASE=$VITE_BASE
RUN npm run build
# --- Serve stage ---------------------------------------------------------------
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/default.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
+136
View File
@@ -0,0 +1,136 @@
# Guitar Practice Helper
A static web app that turns the drill codes from my Obsidian practice wiki (R1, F2,
T3, …) into interactive helper pages: click tracks, pattern grids, chord & fretboard
diagrams, and a drone. Open a drill link from the wiki on phone or desktop, press
play, practise for ~5 minutes. No accounts, no backend, no analytics.
Built with **Svelte 5 + Vite** as a fully static SPA. Music theory is powered by
[`tonal`](https://github.com/tonaljs/tonal). Timing uses a Web Audio lookahead
scheduler so the metronome never drifts.
## Develop
```bash
npm install
npm run dev # local dev server
npm run check # svelte-check (typecheck)
npm test # vitest (scheduler timing + music-theory correctness)
npm run build # typecheck + production build to dist/
npm run preview # serve the built dist/ locally
```
## Deploy
The build is fully static (`dist/`). Because the app uses **path routing**
(clean URLs like `/drill/R3`), the host must serve `index.html` for unknown paths
(SPA fallback), and the base path is baked in at build time.
### Base path
- Dedicated domain / served at the root: default, `base = '/'`.
- Subpath (e.g. `https://example.com/practice/`): build with
`VITE_BASE=/practice/ npm run build`.
### Cloudflare Pages
Framework preset: none. Build command `npm run build`, output directory `dist`.
`public/_redirects` (already included) provides the SPA fallback:
```
/* /index.html 200
```
For a subpath, set the `VITE_BASE` environment variable in the Pages build settings.
### Docker + nginx
A multi-stage `Dockerfile` (Node build → `nginx:alpine`) with the correct config
(`nginx/default.conf`: SPA fallback via `try_files`, immutable caching for hashed
assets, `no-cache` for the HTML shell, gzip):
```bash
docker build -t guitar-practice . # root deploy
docker build --build-arg VITE_BASE=/practice/ -t guitar-practice . # subpath
docker run -p 8080:80 guitar-practice # http://localhost:8080
```
### Existing nginx server
Serve `dist/` and add the SPA fallback to your location block:
```nginx
location / {
try_files $uri $uri/ /index.html;
}
```
## Deep-link URL format
The whole point — link to any drill directly from the wiki:
```
https://<host>/<base>drill/<CODE>
```
Optional query parameters override the stored/default settings:
| Param | Applies to | Example |
|------------|-----------------------|----------------|
| `bpm` | click-track drills | `?bpm=66` |
| `variant` | drills with variants | `?variant=2-3-2` |
| `key` | T2, T4 | `?key=D` |
| `root` | T3, T5, T6, L4 | `?root=A` |
| `interval` | T5 | `?interval=P5` |
| `mode` | T6 | `?mode=dorian` |
| `pair` | R4 | `?pair=g-c` |
Examples:
```
https://example.com/practice/drill/R3?bpm=66&variant=2-3-2
https://example.com/practice/drill/T6?root=A&mode=dorian
https://example.com/practice/drill/R4?pair=f-bm
```
Unknown codes resolve to a friendly “unknown drill” page linking back to the index
(`/` shows all drills grouped by category).
## Drills
| Code | Name | Helper |
|------|------|--------|
| R1 | 16th-note strum, mute the off-beats | metronome + strum grid |
| R2 | Accent patterns | metronome + strum grid |
| R3 | 7/8 loop (drop to 4/4 live) | metronome + strum grid |
| R4 | One-minute chord changes | countdown + tap counter + chord diagrams |
| R5 | Palm-mute chugging | metronome + strum grid |
| F1 | Travis picking | metronome + picking lane + chords |
| F2 | PIMA arpeggio over IVviIV | metronome + picking lane + chords |
| F3 | Thumb-independent bass + melody | metronome + picking lane |
| T1 | Name every note on one string | metronome prompter + fretboard |
| T2 | CAGED shapes | fretboard, prev/next |
| T3 | Triads on a 3-string set | chord diagrams, inversions |
| T4 | Diatonic chords of a key | chord diagram row |
| T5 | Intervals from a root | fretboard |
| T6 | Mode over a drone | drone + fretboard |
| L1 | Alternate picking builder | metronome + picking lane |
| L2 | Legato runs (triplets) | metronome + picking lane |
| L3 | String skipping | metronome + picking lane |
| L4 | Bends and vibrato | reference tone |
| E1 | Interval recognition | info |
| E2 | Chord quality recognition | info |
| E3 | Play along by ear | info + drone |
## Keyboard (desktop)
- **Space** — play / pause
- **↑ / ↓** — BPM ±2
## Notes
- Audio unlocks on the first tap/keypress (mobile autoplay policy).
- Per-drill settings (BPM, variant, key, best scores) persist to `localStorage`
under the `gph:` namespace.
- Screen Wake Lock keeps the phone awake while a click or drone is playing.
- Respects `prefers-reduced-motion`: the beat still advances, without the glow/swing.
+27
View File
@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# Hashed build assets are immutable — cache hard.
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Never cache the HTML shell so new deploys are picked up.
location = /index.html {
add_header Cache-Control "no-cache";
}
# SPA fallback: path-routed deep links (e.g. /drill/R3) must serve the app.
location / {
try_files $uri $uri/ /index.html;
}
}
+1
View File
@@ -0,0 +1 @@
/* /index.html 200
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { onDestroy } from 'svelte'
import { Note } from 'tonal'
import { CHROMATIC_ROOTS } from '../../lib/music/theory'
import { Drone } from '../../lib/audio/drone'
import { keepAwake, allowSleep } from '../../lib/wakeLock'
interface Props {
root?: string
octave?: number
}
// Bindable so a parent (T6) can tie a fretboard to the drone's root.
let { root = $bindable('A'), octave = $bindable(3) }: Props = $props()
let volume = $state(0.35)
let playing = $state(false)
const drone = new Drone()
let midi = $derived(Note.midi(`${root}${octave}`) ?? 57)
// Follow root/octave changes live while playing.
$effect(() => {
if (playing) drone.setNote(midi)
})
async function toggle() {
if (playing) {
drone.stop()
playing = false
allowSleep()
} else {
await drone.start(midi)
drone.setVolume(volume)
playing = true
keepAwake()
}
}
function onVol(e: Event) {
volume = Number((e.target as HTMLInputElement).value)
drone.setVolume(volume)
}
onDestroy(() => {
drone.stop()
allowSleep()
})
</script>
<section class="drone">
<button class="play" class:playing onclick={toggle} aria-label={playing ? 'Stop drone' : 'Play drone'}>
<span class="glyph">{playing ? '❚❚' : '▶'}</span>
{playing ? 'Stop drone' : 'Play drone'}
<span class="note num">{root}<sub>{octave}</sub></span>
</button>
<div class="row">
<span class="eyebrow">Root</span>
<div class="chips">
{#each CHROMATIC_ROOTS as r}
<button class="chip" class:sel={r === root} onclick={() => (root = r)}>{r}</button>
{/each}
</div>
</div>
<div class="row inline">
<div class="oct">
<span class="eyebrow">Octave</span>
<div class="chips">
{#each [2, 3, 4] as o}
<button class="chip" class:sel={o === octave} onclick={() => (octave = o)}>{o}</button>
{/each}
</div>
</div>
<label class="vol">
<span class="eyebrow">Volume</span>
<input type="range" min="0" max="1" step="0.01" value={volume} oninput={onVol} aria-label="Drone volume" />
</label>
</div>
</section>
<style>
.drone {
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: var(--radius-lg);
padding: 1.1rem;
margin-bottom: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.play {
width: 100%;
padding: 0.9rem;
font-family: var(--font-display);
font-size: var(--step-2);
background: var(--tubeglow);
color: #201607;
border: none;
border-radius: var(--radius);
font-weight: 600;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.play.playing { background: transparent; color: var(--tubeglow); border: 1px solid var(--tubeglow-soft); }
.note { margin-left: 0.4rem; font-weight: 500; }
.note sub { font-size: 0.6em; }
.row { display: flex; flex-direction: column; gap: 0.5rem; }
.row.inline { flex-direction: row; align-items: flex-end; justify-content: space-between; gap: 1rem; }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.chip {
min-width: 2.5rem;
padding: 0.4rem 0.55rem;
background: var(--walnut-hi);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-family: var(--font-mono);
font-size: var(--step-0);
text-align: center;
}
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); }
.vol { display: flex; flex-direction: column; gap: 0.5rem; flex: 1; max-width: 45%; }
.vol input { accent-color: var(--tubeglow); width: 100%; }
</style>
+35
View File
@@ -0,0 +1,35 @@
<script lang="ts">
import type { InfoDrill } from '../../drills'
import DrillLayout from '../common/DrillLayout.svelte'
import DronePlayer from '../drone/DronePlayer.svelte'
interface Props {
drill: InfoDrill
params: URLSearchParams
}
// params kept for a consistent view signature.
let { drill }: Props = $props()
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
{#if drill.hasDrone}
<DronePlayer />
{/if}
<div class="prose">
{#each drill.body as para}
<p>{para}</p>
{/each}
</div>
</DrillLayout>
<style>
.prose {
color: var(--inlay-dim);
max-width: 52ch;
}
.prose p {
margin: 0 0 1rem;
line-height: 1.6;
}
</style>
+65
View File
@@ -0,0 +1,65 @@
<script lang="ts">
import type { TheoryDrill } from '../../drills'
import { MODES, scaleChromas, scaleNotes, chroma } from '../../lib/music/theory'
import { positionsForChromas } from '../../lib/music/fretboard'
import * as storage from '../../lib/storage'
import DrillLayout from '../common/DrillLayout.svelte'
import DronePlayer from '../drone/DronePlayer.svelte'
import Fretboard from '../diagrams/Fretboard.svelte'
interface Props {
drill: TheoryDrill
params: URLSearchParams
}
let { drill, params }: Props = $props()
const initRoot = () => params.get('root') ?? storage.getString(`${drill.code}:root`, 'A')
const initMode = () => params.get('mode') ?? storage.getString(`${drill.code}:mode`, 'dorian')
let root = $state(initRoot())
let octave = $state(3)
let modeId = $state(initMode())
let mode = $derived(MODES.find((m) => m.id === modeId) ?? MODES[1])
let notes = $derived(scaleNotes(root, mode.id))
let positions = $derived(positionsForChromas(new Set(scaleChromas(root, mode.id)), 12))
let rootPos = $derived(positionsForChromas(new Set([chroma(root)]), 12))
// Persist selections (root also comes from the bound DronePlayer).
$effect(() => {
storage.setString(`${drill.code}:root`, root)
storage.setString(`${drill.code}:mode`, modeId)
})
</script>
<DrillLayout code={drill.code} name={drill.name} category={drill.category} description={drill.description}>
<DronePlayer bind:root bind:octave />
<div class="modes">
<span class="eyebrow">Mode</span>
<div class="chips">
{#each MODES as m}
<button class="chip" class:sel={m.id === modeId} onclick={() => (modeId = m.id)}>{m.label}</button>
{/each}
</div>
</div>
<p class="notes num">{root} {mode.label}: {notes.join(' · ')}</p>
<Fretboard {positions} roots={rootPos} fromFret={0} toFret={12} labels />
</DrillLayout>
<style>
.modes { display: flex; flex-direction: column; gap: 0.5rem; margin-bottom: 1rem; }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; }
.chip {
padding: 0.45rem 0.7rem;
background: var(--walnut);
border: 1px solid var(--fretwire-dim);
border-radius: 999px;
color: var(--inlay-dim);
font-size: var(--step-0);
}
.chip.sel { color: var(--tubeglow); border-color: var(--tubeglow-soft); background: color-mix(in srgb, var(--tubeglow) 10%, transparent); }
.notes { color: var(--inlay-dim); font-size: var(--step-0); margin: 0 0 1rem; }
</style>
+51 -1
View File
@@ -467,6 +467,13 @@ const T5: TheoryDrill = {
description: 'See a root and an interval shape on adjacent strings.', description: 'See a root and an interval shape on adjacent strings.',
kind: 'intervals', kind: 'intervals',
} }
const T6: TheoryDrill = {
code: 'T6',
name: 'Mode over a drone',
category: 'Theory',
description: 'Hold a root drone and improvise the selected mode against it.',
kind: 'mode-drone',
}
// ============================================================================== // ==============================================================================
// LEAD — reference // LEAD — reference
@@ -480,13 +487,56 @@ const L4: TheoryDrill = {
kind: 'reference-tone', kind: 'reference-tone',
} }
// ==============================================================================
// EAR
// ==============================================================================
const E1: InfoDrill = {
code: 'E1',
name: 'Interval recognition',
category: 'Ear',
description: 'Train your ear to name intervals by sound.',
kind: 'info',
body: [
'Sing or hum a reference, then play two notes and name the interval between them before checking.',
'Anchor each interval to a song you know (e.g. a perfect 4th = the opening of “Here Comes the Bride”).',
'Five minutes daily beats a long session once a week. Use the T5 helper to see interval shapes on the neck.',
],
}
const E2: InfoDrill = {
code: 'E2',
name: 'Chord quality recognition',
category: 'Ear',
description: 'Hear major vs minor vs dominant vs diminished.',
kind: 'info',
body: [
'Play a random chord and name its quality before looking: major, minor, dominant 7, diminished.',
'Start with just major vs minor. Add sevenths once that is automatic.',
'Alternate between playing and just listening with your eyes closed.',
],
}
const E3: InfoDrill = {
code: 'E3',
name: 'Play along by ear',
category: 'Ear',
description: 'Find melodies and changes over a drone or backing track.',
kind: 'info',
hasDrone: true,
body: [
'Hold the drone below and find scale tones and simple melodies against it by ear.',
'When you are ready, swap the drone for a real backing track and work out the changes.',
'Backing tracks: search your favourite source for the key and feel you want, then loop it while you explore.',
],
}
// ============================================================================== // ==============================================================================
export const DRILLS: Drill[] = [ export const DRILLS: Drill[] = [
R1, R2, R3, R4, R5, R1, R2, R3, R4, R5,
F1, F2, F3, F1, F2, F3,
T1, T2, T3, T4, T5, T1, T2, T3, T4, T5, T6,
L1, L2, L3, L4, L1, L2, L3, L4,
E1, E2, E3,
] ]
export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries( export const DRILLS_BY_CODE: Record<string, Drill> = Object.fromEntries(
+28
View File
@@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest'
import { stepDuration } from './Metronome'
describe('stepDuration', () => {
it('computes seconds per step from bpm and subdivision', () => {
expect(stepDuration(120, 1)).toBeCloseTo(0.5, 10) // quarter at 120
expect(stepDuration(120, 2)).toBeCloseTo(0.25, 10) // eighths
expect(stepDuration(120, 4)).toBeCloseTo(0.125, 10) // sixteenths
expect(stepDuration(90, 3)).toBeCloseTo(60 / 90 / 3, 10) // eighth triplets
})
})
describe('scheduler timing does not drift', () => {
// The scheduler advances nextNoteTime by repeated addition (as in Metronome.advance).
// Verify accumulated error stays negligible over 5 minutes of continuous play.
it('stays within 1µs of analytic time over 5 minutes', () => {
const bpm = 300
const stepsPerBeat = 4
const dur = stepDuration(bpm, stepsPerBeat)
const totalSteps = Math.ceil((5 * 60) / dur) // ~6000 steps
let t = 0
for (let i = 0; i < totalSteps; i++) t += dur
const analytic = totalSteps * dur
expect(Math.abs(t - analytic)).toBeLessThan(1e-6)
})
})
+84
View File
@@ -0,0 +1,84 @@
// Sustained drone pad: two slightly detuned oscillators through a lowpass filter.
// Smooth attack/release avoids clicks. Frequency and volume can change while playing.
import { audio } from './AudioEngine'
import { midiToFreq } from './tone'
export class Drone {
isPlaying = false
private osc1: OscillatorNode | null = null
private osc2: OscillatorNode | null = null
private gain: GainNode | null = null
private filter: BiquadFilterNode | null = null
private level = 0.35
async start(midi: number): Promise<void> {
if (this.isPlaying) {
this.setNote(midi)
return
}
await audio.unlock()
const ctx = audio.context
const now = ctx.currentTime
const freq = midiToFreq(midi)
this.osc1 = ctx.createOscillator()
this.osc2 = ctx.createOscillator()
this.gain = ctx.createGain()
this.filter = ctx.createBiquadFilter()
this.osc1.type = 'sawtooth'
this.osc2.type = 'sawtooth'
this.osc1.frequency.value = freq
this.osc2.frequency.value = freq
this.osc1.detune.value = -6
this.osc2.detune.value = 6
this.filter.type = 'lowpass'
this.filter.frequency.value = 900
this.filter.Q.value = 0.8
this.gain.gain.setValueAtTime(0.0001, now)
this.gain.gain.exponentialRampToValueAtTime(this.level, now + 0.25)
this.osc1.connect(this.filter)
this.osc2.connect(this.filter)
this.filter.connect(this.gain)
this.gain.connect(audio.master)
this.osc1.start(now)
this.osc2.start(now)
this.isPlaying = true
}
setNote(midi: number): void {
if (!this.osc1 || !this.osc2) return
const now = audio.currentTime
const freq = midiToFreq(midi)
this.osc1.frequency.linearRampToValueAtTime(freq, now + 0.08)
this.osc2.frequency.linearRampToValueAtTime(freq, now + 0.08)
}
setVolume(v: number): void {
this.level = Math.max(0, Math.min(1, v))
if (this.gain) {
const now = audio.currentTime
this.gain.gain.linearRampToValueAtTime(this.level, now + 0.05)
}
}
stop(): void {
if (!this.isPlaying || !this.gain || !this.osc1 || !this.osc2) return
const now = audio.currentTime
this.gain.gain.cancelScheduledValues(now)
this.gain.gain.setValueAtTime(this.gain.gain.value, now)
this.gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.2)
this.osc1.stop(now + 0.25)
this.osc2.stop(now + 0.25)
this.isPlaying = false
this.osc1 = null
this.osc2 = null
this.gain = null
this.filter = null
}
}
@@ -4,6 +4,7 @@
import { Metronome, type MetroPattern } from './Metronome' import { Metronome, type MetroPattern } from './Metronome'
import { audio } from './AudioEngine' import { audio } from './AudioEngine'
import { keepAwake, allowSleep } from '../wakeLock'
export const BPM_MIN = 30 export const BPM_MIN = 30
export const BPM_MAX = 300 export const BPM_MAX = 300
@@ -43,12 +44,14 @@ export class MetronomeController {
await audio.unlock() await audio.unlock()
this.metro.start() this.metro.start()
this.isPlaying = true this.isPlaying = true
keepAwake()
this.tick() this.tick()
} }
stop(): void { stop(): void {
this.metro.stop() this.metro.stop()
this.isPlaying = false this.isPlaying = false
allowSleep()
cancelAnimationFrame(this.raf) cancelAnimationFrame(this.raf)
this.currentStep = -1 this.currentStep = -1
} }
+67
View File
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest'
import { chromaAt, pcAt } from './fretboard'
import { triadNotes, diatonicChords, scaleNotes, intervalTarget } from './theory'
import { triadShape, STRING_SETS } from './triads'
import { cagedShapes } from './caged'
import { deriveRoles } from '../../drills'
describe('fretboard mapping', () => {
it('maps open strings and frets correctly (index 0 = high e)', () => {
expect(pcAt(0, 0)).toBe('E') // high e open
expect(pcAt(5, 0)).toBe('E') // low E open
expect(pcAt(5, 3)).toBe('G') // low E, 3rd fret
expect(pcAt(4, 2)).toBe('B') // A string, 2nd fret
expect(chromaAt(0, 0)).toBe(4) // E
})
})
describe('theory helpers', () => {
it('builds triads and diatonic chords', () => {
expect(triadNotes('C', 'major')).toEqual(['C', 'E', 'G'])
expect(triadNotes('A', 'minor')).toEqual(['A', 'C', 'E'])
expect(diatonicChords('G').map((c) => c.name)).toEqual([
'G', 'Am', 'Bm', 'C', 'D', 'Em', 'F#dim',
])
expect(scaleNotes('G', 'dorian')).toEqual(['G', 'A', 'Bb', 'C', 'D', 'E', 'F'])
expect(intervalTarget('A', '5P')).toBe('E')
})
})
describe('deriveRoles', () => {
it('accents, downbeats, and subdivisions land correctly', () => {
// 4/4 sixteenths, accent on beat 1.
const roles = deriveRoles(16, 4, [0])
expect(roles[0]).toBe('accent')
expect(roles[4]).toBe('normal') // beat 2 downbeat
expect(roles[1]).toBe('subdivision')
})
})
describe('triad inversions', () => {
it('produces C major tones on the 1-2-3 string set', () => {
const set = STRING_SETS.find((s) => s.id === '123')!
for (const inv of ['root', '1st', '2nd']) {
const shape = triadShape('C', 'major', set, inv)
const chromas = shape.frets
.map((f, string) => (f >= 0 ? chromaAt(string, f) : null))
.filter((c): c is number => c !== null)
.sort((a, b) => a - b)
// C(0) E(4) G(7)
expect(chromas).toEqual([0, 4, 7])
}
})
})
describe('CAGED shapes', () => {
it('returns 5 shapes with the E-shape barre at fret 3 for key G', () => {
const shapes = cagedShapes('G')
expect(shapes).toHaveLength(5)
const eShape = shapes.find((s) => s.form === 'E shape')!
expect(eShape.barreFret).toBe(3)
// Every marked position should be a chord tone of G major (G B D).
const gTones = new Set([7, 11, 2])
for (const p of shapes[0].positions) {
expect(gTones.has(chromaAt(p.string, p.fret))).toBe(true)
}
})
})
+51
View File
@@ -0,0 +1,51 @@
// Screen Wake Lock so the phone doesn't sleep mid-drill while propped against the
// amp. Feature-detected; silent no-op where unsupported. Re-acquires on tab return.
interface WakeLockSentinelLike {
released: boolean
release(): Promise<void>
}
interface WakeLockLike {
request(type: 'screen'): Promise<WakeLockSentinelLike>
}
function api(): WakeLockLike | null {
const nav = navigator as unknown as { wakeLock?: WakeLockLike }
return nav.wakeLock ?? null
}
let sentinel: WakeLockSentinelLike | null = null
let desired = false
let wired = false
async function acquire(): Promise<void> {
const wl = api()
if (!wl || sentinel) return
try {
sentinel = await wl.request('screen')
} catch {
/* user gesture required or blocked — ignore */
}
}
function wire(): void {
if (wired) return
wired = true
document.addEventListener('visibilitychange', () => {
if (desired && document.visibilityState === 'visible') void acquire()
})
}
/** Keep the screen awake. Call from a user gesture (e.g. pressing play). */
export function keepAwake(): void {
desired = true
wire()
void acquire()
}
/** Allow the screen to sleep again. */
export function allowSleep(): void {
desired = false
void sentinel?.release()
sentinel = null
}
+6
View File
@@ -10,6 +10,8 @@
import IntervalsView from '../components/views/IntervalsView.svelte' import IntervalsView from '../components/views/IntervalsView.svelte'
import NotePrompterView from '../components/views/NotePrompterView.svelte' import NotePrompterView from '../components/views/NotePrompterView.svelte'
import ReferenceToneView from '../components/views/ReferenceToneView.svelte' import ReferenceToneView from '../components/views/ReferenceToneView.svelte'
import ModeDroneView from '../components/views/ModeDroneView.svelte'
import InfoView from '../components/views/InfoView.svelte'
interface Props { interface Props {
code: string code: string
@@ -42,6 +44,10 @@
<NotePrompterView {drill} {params} /> <NotePrompterView {drill} {params} />
{:else if drill.kind === 'reference-tone'} {:else if drill.kind === 'reference-tone'}
<ReferenceToneView {drill} {params} /> <ReferenceToneView {drill} {params} />
{:else if drill.kind === 'mode-drone'}
<ModeDroneView {drill} {params} />
{:else if drill.kind === 'info'}
<InfoView {drill} {params} />
{:else} {:else}
<NotFound {code} /> <NotFound {code} />
{/if} {/if}