Skip to content
Open
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
Binary file added assets/audio/ot_d5.mp3
Binary file not shown.
88 changes: 87 additions & 1 deletion assets/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,92 @@
},
"overview": {
"listen": "Listen",
"compose": "Compose"
"compose": "Compose",
"practice": "Practice"
},
"practice": {
"pick-tune": "Pick a tune from the sidebar to start practicing.",
"no-hits": "No strokes to practice for this instrument.",
"results": {
"title": "Session results"
},
"score": {
"title": "Score",
"avgDelta": "Timing",
"drift": "Drift",
"hits": "Hits",
"misses": "Misses",
"extras": "Extras",
"behind": "behind",
"early": "early",
"sessionTotals": "Session totals",
"idle": "Press ▶ to begin"
},
"mic": {
"listening": "listening",
"stopped": "stopped"
},
"toolbar": {
"start": "Start",
"stop": "Stop",
"part": "Part",
"instrument": "Instrument",
"modeInstrument": "Instrument",
"modeBand": "Band"
},
"speed": {
"label": "Speed",
"reset": "Reset"
},
"difficulty": {
"label": "Difficulty",
"easy": "Easy",
"normal": "Normal",
"hard": "Hard"
},
"latency": {
"label": "Latency offset",
"calibrate": "Calibrate…"
},
"sensitivity": {
"label": "Sensitivity",
"reset": "Reset"
},
"settings": {
"title": "Settings"
},
"calibration": {
"title": "Calibrate latency",
"idle-instructions": "This plays a series of clicks through your speakers and listens with your microphone to measure audio latency. Turn your speakers up, keep the room quiet, then press Start.",
"listening": "Listening… measuring latency, please wait.",
"result-median": "Measured latency: {{median}} ms",
"result-spread": "Spread: {{spread}} ms",
"spread-warning": "High spread — consider trying again.",
"fail-too-few": "Couldn't hear enough clicks — turn your volume up, move the mic closer, or reduce background noise.",
"fail-too-noisy": "Readings were inconsistent — try again somewhere quieter.",
"fail-worklet": "Audio setup failed — please try again.",
"fail-mic-denied": "Microphone access is needed to measure latency. Enable it in your browser, or set the offset manually with the slider.",
"retry": "Try again",
"cancel": "Cancel",
"start": "Start",
"apply": "Apply {{median}} ms"
},
"permission": {
"title": "Use your microphone?",
"body": "To score your timing, Practice listens to the microphone and compares what it hears with the partition.",
"privacy": "Audio is processed locally on your device and never leaves it.",
"cancel": "Cancel",
"enable": "Enable microphone"
},
"headphones": {
"title": "Headphones recommended",
"body": "For accurate scoring, please use headphones — otherwise the microphone will hear the playback, not just you.",
"continue": "Continue"
},
"rotate": {
"title": "Rotate to landscape",
"hint": "Practice needs the width to show your timing and the pattern together."
}
},
"pattern-list-filter": {
"filter-placeholder": "Filter: {{category}}"
Expand Down Expand Up @@ -264,6 +349,7 @@
"stroke-description-.": "Silent stroke",
"stroke-description-wh": "Whistle",
"stroke-description-wh2": "Long whistle",
"stroke-description-whistle-in": "Whistle-in cue (single long blast)",
"stroke-description-s": "Soft flare",
"time-with-triplets": "{{time}} with triplets",
"time-with-quintuplets": "{{time}} with quintuplets",
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"test-watch": "vitest"
},
"devDependencies": {
"@types/audioworklet": "^0.0.100",
"@types/bootstrap": "^5.2.10",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
Expand All @@ -45,6 +46,9 @@
"vue-tsc": "^3.2.2"
},
"author": "Candid Dauth",
"resolutions": {
"beatbox.js": "5.0.1"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
Expand Down
4 changes: 2 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,15 @@ import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import Overview from "./ui/overview.vue";
import { registerServiceWorker } from "./services/service-worker";
import { library } from '@fortawesome/fontawesome-svg-core'
import { faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose } from '@fortawesome/free-solid-svg-icons'
import { faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMicrophone, faMicrophoneSlash, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose } from '@fortawesome/free-solid-svg-icons'
import Vue3TouchEvents, { Vue3TouchEventsOptions } from "vue3-touch-events";
import { ensurePersistentStorage, reactiveLocalStorage } from "./services/localStorage";
import { reactiveLocationHash } from "./services/router";
import { theme } from "./services/bootstrap";

registerServiceWorker();

library.add(faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose);
library.add(faBars, faCaretDown, faCheck, faClock, faCode, faCog, faCopy, faDownload, faEraser, faExclamationCircle, faInfoCircle, faFileExport, faFileImport, faHandPointRight, faHeadphones, faMicrophone, faMicrophoneSlash, faMobileAlt, faMusic, faPause, faPen, faPencilAlt, faPlay, faPlayCircle, faPlus, faQuestionCircle, faShare, faSlidersH, faStar, faStop, faTrash, faVolumeMute, faWindowClose);

const Root = defineComponent({
setup() {
Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const config: Config = {
},
ot: {
name: () => getI18n().t("config.instruments-ot"),
strokes: [ "w", "y", "A", "B", "D", "E", "F", "G", "J", "K", "L", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "Y", "Z", "9", "8", "7", "6", "5", "b", "c", "d", "e", "g", "q", "j", "k", "m", "n", "u", "v", "x", "i", "l", "p", "$", "%", "&", "'", "(", ")", "*", ",", "-", "?", ":", ";", "<", "=", ">", "K", "[", "\\", "^", "_", "`", "{", "|", "}", "~", "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î", "Ï", "İ", "Ǐ", "Ī", "Ĩ", "Į", "IJ", "Ð", "Ñ", "Ò", "Ó", "Ô" ]
strokes: [ "w", "y", "A", "B", "D", "E", "F", "G", "J", "K", "L", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "Y", "Z", "9", "8", "7", "6", "5", "b", "c", "d", "e", "g", "q", "j", "k", "m", "n", "u", "v", "x", "i", "l", "p", "$", "%", "&", "'", "(", ")", "*", ",", "-", "?", ":", ";", "<", "=", ">", "K", "[", "\\", "^", "_", "`", "{", "|", "}", "~", "À", "Á", "Â", "Ã", "Ä", "Å", "Æ", "Ç", "È", "É", "Ê", "Ë", "Ì", "Í", "Î", "Ï", "İ", "Ǐ", "Ī", "Ĩ", "Į", "IJ", "Ð", "Ñ", "Ò", "Ó", "Ô", "Õ" ]
}
},

Expand Down Expand Up @@ -227,6 +227,7 @@ const config: Config = {
"Ò": "que",
"Ó": "re",
"Ô": "mos",
"Õ": "Whi",
// ]
},

Expand All @@ -239,7 +240,8 @@ const config: Config = {
".": () => getI18n().t("config.stroke-description-."),
"w" :() => getI18n().t("config.stroke-description-wh"),
"y" :() => getI18n().t("config.stroke-description-wh2"),
"z": () => getI18n().t("config.stroke-description-s")
"z": () => getI18n().t("config.stroke-description-s"),
"Õ": () => getI18n().t("config.stroke-description-whistle-in")
},

volumePresets: {
Expand Down
54 changes: 54 additions & 0 deletions src/services/__tests__/latencyCalibrator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { expect, test } from "vitest";
import { computeMedianAndSpread, createCalibrationSession } from "../latencyCalibrator";

test("computeMedianAndSpread: empty input", () => {
expect(computeMedianAndSpread([])).toEqual({ median: 0, spread: 0, count: 0 });
});

test("computeMedianAndSpread: single value", () => {
expect(computeMedianAndSpread([42])).toEqual({ median: 42, spread: 0, count: 1 });
});

test("computeMedianAndSpread: odd count", () => {
expect(computeMedianAndSpread([10, 20, 30])).toEqual({
median: 20, spread: 10, count: 3, // spread = MAD: median of |v − 20| = median([10, 0, 10]) = 10
});
});

test("computeMedianAndSpread: even count averages middle two", () => {
expect(computeMedianAndSpread([10, 20, 30, 40])).toEqual({
median: 25, spread: 10, count: 4,
});
});

test("calibration session collects deltas against scheduled beats", () => {
const beats = [0, 500, 1000, 1500]; // 4 beats, 500ms apart
const session = createCalibrationSession(beats);
session.recordTap(50); // 50ms after beat 0
session.recordTap(548); // 48ms after beat 1 (closest)
session.recordTap(1051); // 51ms after beat 2
session.recordTap(1547); // 47ms after beat 3
const result = session.finalize();
expect(result.count).toBe(4);
expect(result.median).toBe(49);
expect(result.spread).toBeLessThanOrEqual(2);
});

test("calibration session ignores taps outside any beat's window", () => {
const beats = [0, 500];
const session = createCalibrationSession(beats, { windowMs: 200 });
session.recordTap(100); // within window of beat 0
session.recordTap(5000); // far away — dropped
expect(session.finalize().count).toBe(1);
});

test("calibration session records negative delta for tap before beat", () => {
const session = createCalibrationSession([500]);
session.recordTap(450); // 50ms before beat
expect(session.finalize().median).toBe(-50);
});

test("calibration session finalize with no taps returns zeros", () => {
const session = createCalibrationSession([0, 500, 1000]);
expect(session.finalize()).toEqual({ median: 0, spread: 0, count: 0 });
});
65 changes: 65 additions & 0 deletions src/services/__tests__/loopbackCalibrator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from "vitest";
import { createLoopbackCalibration, DEFAULT_LOOPBACK_QUALITY } from "../loopbackCalibrator";

function beatsAt(n: number, spacingMs: number, start = 100_000): number[] {
return Array.from({ length: n }, (_, i) => start + i * spacingMs);
}

test("recovers a known constant latency", () => {
const beats = beatsAt(11, 600);
const cal = createLoopbackCalibration(beats);
for (const b of beats) cal.recordOnset(b + 40);
const r = cal.finalize();
expect(r.medianMs).toBe(40);
expect(r.spread).toBe(0);
expect(r.count).toBe(11);
expect(r.accepted).toBe(true);
expect(r.reason).toBeUndefined();
});

test("accepts a latency with small jitter", () => {
const beats = beatsAt(11, 600);
const cal = createLoopbackCalibration(beats);
const jitter = [0, 5, -5, 8, -8, 3, -3, 6, -6, 2, -2];
beats.forEach((b, i) => cal.recordOnset(b + 40 + jitter[i]));
const r = cal.finalize();
expect(r.accepted).toBe(true);
expect(Math.abs(r.medianMs - 40)).toBeLessThanOrEqual(5);
});

test("rejects too-few detections", () => {
const beats = beatsAt(11, 600);
const cal = createLoopbackCalibration(beats);
cal.recordOnset(beats[0] + 40);
cal.recordOnset(beats[1] + 40);
cal.recordOnset(beats[2] + 40);
const r = cal.finalize();
expect(r.count).toBe(3);
expect(r.accepted).toBe(false);
expect(r.reason).toBe("too-few");
});

test("rejects too-noisy spread", () => {
const beats = beatsAt(11, 600);
const cal = createLoopbackCalibration(beats);
const deltas = [0, 10, 5, 15, 8, 200, 195, 205, 190, 210, 100];
beats.forEach((b, i) => cal.recordOnset(b + deltas[i]));
const r = cal.finalize();
expect(r.count).toBe(11);
expect(r.accepted).toBe(false);
expect(r.reason).toBe("too-noisy");
});

test("marginal spread band sits strictly inside the accept band", () => {
expect(DEFAULT_LOOPBACK_QUALITY.marginalSpread).toBeLessThan(DEFAULT_LOOPBACK_QUALITY.maxSpread);
});

test("ignores onsets outside the match window", () => {
const beats = beatsAt(11, 600);
const cal = createLoopbackCalibration(beats);
for (const b of beats) cal.recordOnset(b + 40);
cal.recordOnset(beats[beats.length - 1] + 400); // beyond the last beat + window → ignored
const r = cal.finalize();
expect(r.count).toBe(11);
expect(r.medianMs).toBe(40);
});
33 changes: 33 additions & 0 deletions src/services/__tests__/mediaPermissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { expect, test, vi, beforeEach } from "vitest";
import { createMicPermission } from "../mediaPermissions";

beforeEach(() => {
// happy-dom has no navigator.mediaDevices by default
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: vi.fn() },
});
});

test("initial state is 'unknown'", () => {
const p = createMicPermission();
expect(p.state.value).toBe("unknown");
});

test("request → granted updates state and resolves with the stream", async () => {
const fakeStream = {} as MediaStream;
(navigator.mediaDevices.getUserMedia as any).mockResolvedValue(fakeStream);
const p = createMicPermission();
const result = await p.request();
expect(result).toBe(fakeStream);
expect(p.state.value).toBe("granted");
});

test("request → denied sets denied state and rethrows", async () => {
(navigator.mediaDevices.getUserMedia as any).mockRejectedValue(
Object.assign(new Error("denied"), { name: "NotAllowedError" })
);
const p = createMicPermission();
await expect(p.request()).rejects.toThrow();
expect(p.state.value).toBe("denied");
});
9 changes: 9 additions & 0 deletions src/services/__tests__/onsetDetector.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { expect, test } from "vitest";
import { ctxTimeToPerfTime } from "../onsetDetector";

test("ctxTimeToPerfTime converts via an output-timestamp snapshot", () => {
// Snapshot: at perf=10000ms, audio ctx was at 5.123s.
// A subsequent onset at ctx=5.223s → perf = 10000 + (5.223 - 5.123) * 1000 = 10100ms
const snap = { contextTime: 5.123, performanceTime: 10000 };
expect(ctxTimeToPerfTime(5.223, snap)).toBeCloseTo(10100, 5);
});
Loading