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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ played (notes, timing, held length and dynamics), and teaches with immediate, sp

No account, no server, no tracking: **audio never leaves your device.** It installs as an app and works offline.

**Try it: [openkeys.live](https://openkeys.live)** · a free, open-source alternative to Yousician, Synthesia,
Simply Piano and flowkey for piano ([how it compares](https://openkeys.live/alternatives/)).

![OpenKeys practice screen: sheet music with coloured notes, falling notes and the on-screen keyboard](docs/practice.png)

## What it does
Expand Down
5 changes: 5 additions & 0 deletions docs/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,8 @@ frustrating feedback**. This file records those choices and why.
36. **Note names on the sheet are hand-coloured pills** (blue right hand, soft purple left hand, as on the
keyboard and falling notes) in a row under each staff, so they never read as part of the black engraving
(or its inverted dark version). Chords stack in pitch order, and the engraving reserves room for the row.
37. **Search pages are static HTML next to the app.** The app is a single client-rendered page, so the pages
that answer searches ("Yousician alternative", "Synthesia in the browser", "learn piano free") are plain
HTML written by `scripts/build-site.mjs` from `site/pages.mjs` after `vite build`, together with
`sitemap.xml`. They use the same tokens and fonts, need no JavaScript, and are excluded from the service
worker's app fallback. Claims about other apps stay general, dated and honest about what OpenKeys lacks.
72 changes: 69 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,80 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="description" content="OpenKeys: an open-source piano teacher that listens to you play, in your browser." />
<title>OpenKeys · Free piano teacher that listens to you play</title>
<meta name="description" content="OpenKeys is a free, open-source piano teacher in your browser. It listens through your mic or MIDI keyboard, waits on the right note and loops the bars you miss. No account." />
<meta name="keywords" content="learn piano online free, piano lessons free, piano teacher app, browser piano app, Yousician alternative, free Yousician, open source Yousician, Synthesia web version, Synthesia online, Synthesia alternative, Simply Piano alternative, flowkey alternative, falling notes piano, MIDI keyboard practice, piano app no subscription" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="application-name" content="OpenKeys" />
<meta name="apple-mobile-web-app-title" content="OpenKeys" />
<link rel="canonical" href="https://openkeys.live/" />
<meta name="theme-color" content="#111111" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>OpenKeys</title>

<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenKeys" />
<meta property="og:url" content="https://openkeys.live/" />
<meta property="og:title" content="OpenKeys · Free piano teacher that listens to you play" />
<meta property="og:description" content="Learn piano in your browser. OpenKeys listens through your mic or MIDI keyboard, waits on the right note and loops the bars you miss. Free, open source, no account." />
<meta property="og:image" content="https://openkeys.live/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="OpenKeys free play screen with a piano keyboard and a piano roll of played notes" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="OpenKeys · Free piano teacher that listens to you play" />
<meta name="twitter:description" content="Learn piano in your browser. Listens through your mic or MIDI keyboard. Free, open source, no account." />
<meta name="twitter:image" content="https://openkeys.live/og-image.jpg" />

<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "OpenKeys",
"url": "https://openkeys.live/",
"description": "A free, open-source piano teacher in the browser. It listens through your microphone or a MIDI keyboard, waits on the right note, tells you what you played instead and loops the bars you keep missing.",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Any (modern web browser)",
"browserRequirements": "Requires JavaScript and Web Audio",
"isAccessibleForFree": true,
"license": "https://opensource.org/licenses/MIT",
"image": "https://openkeys.live/og-image.jpg",
"screenshot": "https://openkeys.live/img/practice.jpg",
"inLanguage": "en",
"author": { "@type": "Person", "name": "Paul Joshi", "url": "https://github.com/PaulJoshi" },
"codeRepository": "https://github.com/PaulJoshi/OpenKeys",
"sameAs": ["https://github.com/PaulJoshi/OpenKeys"],
"keywords": "piano, learn piano, piano lessons, free, open source, MIDI, microphone, falling notes, sheet music, Yousician alternative, Synthesia alternative",
"featureList": [
"Listens through the microphone on any piano or keyboard",
"Exact input from a USB MIDI keyboard",
"Wait mode that stops until you play the right note",
"Falling notes and sheet music, side by side",
"Play along at 25 to 150 percent tempo, and a Follow me mode",
"Loops the bars you keep missing",
"Import MusicXML, MIDI and ABC files",
"7-lesson course plus scales, chords, sight-reading, rhythm and ear-training drills",
"No account, works offline, audio stays on your device"
],
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
}
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebSite",
"name": "OpenKeys",
"alternateName": ["Open Keys", "OpenKeys piano"],
"url": "https://openkeys.live/"
}
</script>
</head>
<body>
<div id="root"></div>
<noscript>OpenKeys needs JavaScript to run.</noscript>
<noscript>
OpenKeys needs JavaScript to run. OpenKeys is a free, open-source piano teacher in your browser: it listens
through your microphone or MIDI keyboard, waits on the right note and loops the bars you keep missing.
<a href="/learn-piano-free/">How it works</a> · <a href="/alternatives/">How it compares with Yousician, Synthesia, Simply Piano and flowkey</a>
</noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
"name": "openkeys",
"private": true,
"version": "0.1.0",
"description": "Open-source, browser-based piano learning app: play, listen, judge, teach.",
"description": "Free, open-source piano teacher in the browser: listens through your mic or MIDI keyboard. A free alternative to Yousician, Synthesia, Simply Piano and flowkey.",
"license": "MIT",
"type": "module",
"scripts": {
"samples": "node scripts/copy-samples.mjs",
"predev": "node scripts/copy-samples.mjs",
"dev": "vite",
"prebuild": "node scripts/copy-samples.mjs",
"build": "tsc -b && vite build",
"build": "tsc -b && vite build && node scripts/build-site.mjs",
"preview": "vite preview",
"typecheck": "tsc -b",
"test": "vitest run",
Expand Down
1 change: 1 addition & 0 deletions public/3071ecf16c40a239023848ab12f05eb7.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3071ecf16c40a239023848ab12f05eb7
Binary file added public/img/practice.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
29 changes: 29 additions & 0 deletions public/llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# OpenKeys

> OpenKeys (https://openkeys.live) is a free, open-source piano teacher that runs in the web browser. It listens to you play through the microphone (acoustic piano or keyboard) or a USB MIDI keyboard, waits on the right note, tells you what you played instead, and loops the bars you keep missing. No account, no subscription, no ads. Audio never leaves the device. MIT licensed.

People often look for OpenKeys as a free or open-source alternative to Yousician, Synthesia (falling notes, in the browser), Simply Piano and flowkey, for piano only.

## What it does

- Input: microphone with a 2-minute setup wizard, USB MIDI keyboard (Chrome, Edge, Firefox; Safari is microphone only), or computer keys.
- Practice modes: Listen, Wait (stops until you play the right notes), Play along at 25-150% tempo, Follow me (follows your tempo), Loop drill (tempo rises 5% after each clean pass). Hands separately or together.
- Views: sheet music, falling notes, or both, with an on-screen keyboard.
- Feedback: notes marked right or wrong in place, wrong notes shown as ghost noteheads, results with a bar-by-bar heatmap, top trouble spots with a one-click practice loop, and coaching such as per-hand timing.
- Learning: a 7-lesson course, generated drills (scales and arpeggios in any key, chord progressions, sight-reading, rhythm, ear training, note reading), daily goal and streak, spaced review of trouble spots.
- Music: 15 built-in public-domain pieces in graded versions (Ode to Joy, Für Elise, Gymnopédie No. 1, Prelude in C, Minuet in G, Canon in D and more); import MusicXML/MXL, MIDI and ABC; built-in ABC editor.
- Free play with a live piano roll; record and transcribe to a score or MIDI, locally.
- Installs as an app and works offline.

## Limits

- Piano and keyboard only.
- A small built-in library (15 pieces) compared with commercial apps; no licensed pop songs; no video lessons.

## Links

- App: https://openkeys.live/
- How it works: https://openkeys.live/learn-piano-free/
- Comparison with Yousician, Synthesia, Simply Piano and flowkey: https://openkeys.live/alternatives/
- Source code: https://github.com/PaulJoshi/OpenKeys
- Microphone accuracy measurements: https://github.com/PaulJoshi/OpenKeys/blob/main/docs/ACCURACY.md
Binary file added public/og-image.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions public/robots.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
User-agent: *
Allow: /

Sitemap: https://openkeys.live/sitemap.xml
166 changes: 166 additions & 0 deletions scripts/build-site.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Writes the static info pages (site/pages.mjs) into dist/ after `vite build`, plus sitemap.xml.
// The pages are plain HTML so search engines read them without running the app. They reuse the app's
// design tokens and the fonts Vite already emitted into dist/assets.
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { PAGES, SITE, COMPARED_ON, UPDATED, FEATURES } from '../site/pages.mjs';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const dist = join(root, 'dist');
const base = process.env.BASE ?? '/';

const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const url = (slug) => `${SITE}/${slug ? slug + '/' : ''}`;
const href = (slug) => `${base}${slug ? slug + '/' : ''}`;

// CSS: tokens with font URLs pointed at Vite's hashed copies, then the page styles.
const assets = readdirSync(join(dist, 'assets'));
const tokens = readFileSync(join(root, 'src/ui/design/tokens.css'), 'utf8').replace(/url\("\.\/fonts\/([^".]+)\.woff2"\)/g, (m, name) => {
const file = assets.find((f) => f.startsWith(name + '-') && f.endsWith('.woff2'));
if (!file) throw new Error(`build-site: no emitted font for ${name}; run vite build first`);
return `url("${base}assets/${file}")`;
});
writeFileSync(join(dist, 'site.css'), tokens + '\n' + readFileSync(join(root, 'site/site.css'), 'utf8'));

const CHEVRON =
'<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>';
const stripTags = (s) => s.replace(/<[^>]+>/g, '');

const header = (page) => `<header class="top"><div class="wrap">
<a class="brand" href="${href('')}">OpenKeys</a>
<nav aria-label="Main">
<a class="hide-sm" href="${href('learn-piano-free')}"${page.slug === 'learn-piano-free' ? ' aria-current="page"' : ''}>How it works</a>
<a class="hide-sm" href="${href('alternatives')}"${page.slug === 'alternatives' ? ' aria-current="page"' : ''}>Compare</a>
<a class="btn sm" href="${href('')}">Open app</a>
</nav></div></header>`;

const footer = () => `<footer><div class="wrap">
<div class="cols">
<div><b>OpenKeys</b><a href="${href('')}">Open the app</a><a href="${href('learn-piano-free')}">Learn piano free</a><a href="https://github.com/PaulJoshi/OpenKeys">Source code on GitHub</a></div>
<div><b>Compare</b><a href="${href('alternatives')}">All piano apps</a>${PAGES.filter((p) => p.them).map((p) => `<a href="${href(p.slug)}">${esc(p.them.name)} alternative</a>`).join('')}</div>
<div><b>Project</b><a href="https://github.com/PaulJoshi/OpenKeys/blob/main/LICENSE">MIT licence</a><a href="https://github.com/PaulJoshi/OpenKeys/blob/main/docs/ACCURACY.md">Microphone accuracy</a><a href="https://github.com/PaulJoshi/OpenKeys/issues">Report a problem</a></div>
</div>
<p class="legal">OpenKeys is free and open source, made by Paul Joshi and contributors. Yousician, Synthesia, Simply Piano and flowkey are trademarks of their owners; OpenKeys is not affiliated with or endorsed by them. Comparisons reflect public information as of ${COMPARED_ON} and may change.</p>
</div></footer>`;

const table = (t) => `<div class="table-scroll"><table>
<thead><tr><th scope="col"></th>${t.cols.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead>
<tbody>${t.rows.map(([label, ...cells]) => `<tr><th scope="row">${esc(label)}</th>${cells.map((c) => `<td>${esc(c)}</td>`).join('')}</tr>`).join('\n')}</tbody>
</table></div>
<p class="note">Compared in ${COMPARED_ON} from each app's public information. Prices and features change, so check their sites.</p>`;

const faq = (items) => `<div class="faq">${items
.map(([q, a]) => `<details><summary>${esc(q)}${CHEVRON}</summary><div class="a"><p>${esc(a)}</p></div></details>`)
.join('\n')}</div>`;

const tiles = () => `<div class="tiles">${FEATURES.map(([h, p]) => `<div class="tile"><h3>${esc(h)}</h3><p>${esc(p)}</p></div>`).join('')}</div>`;

const related = (page) => {
const others = PAGES.filter((p) => p.slug !== page.slug);
const sub = (p) => p.title.split(' · ')[0];
return `<div class="links">${others
.map((p) => `<a href="${href(p.slug)}">${esc(p.h1)}${sub(p) !== p.h1 ? `<span>${esc(sub(p))}</span>` : ''}</a>`)
.join('')}</div>`;
};

function jsonLd(page) {
const crumbs = [{ name: 'OpenKeys', item: url('') }];
if (page.slug !== 'alternatives' && page.them) crumbs.push({ name: 'Compare', item: url('alternatives') });
crumbs.push({ name: page.nav, item: url(page.slug) });
return [
{
'@context': 'https://schema.org',
'@type': 'WebPage',
name: page.title,
description: page.description,
url: url(page.slug),
dateModified: UPDATED,
inLanguage: 'en',
isPartOf: { '@type': 'WebSite', name: 'OpenKeys', url: url('') },
about: { '@type': 'WebApplication', name: 'OpenKeys', url: url(''), applicationCategory: 'EducationalApplication', operatingSystem: 'Any', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } },
},
{
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: crumbs.map((c, i) => ({ '@type': 'ListItem', position: i + 1, name: c.name, item: c.item })),
},
{
'@context': 'https://schema.org',
'@type': 'FAQPage',
mainEntity: page.faq.map(([q, a]) => ({ '@type': 'Question', name: q, acceptedAnswer: { '@type': 'Answer', text: stripTags(a) } })),
},
];
}

function render(page) {
const them = page.them;
return `<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(page.title)}</title>
<meta name="description" content="${esc(page.description)}" />
<link rel="canonical" href="${url(page.slug)}" />
<meta name="robots" content="index, follow, max-image-preview:large" />
<meta name="theme-color" content="#111111" />
<link rel="icon" type="image/svg+xml" href="${base}icon.svg" />
<link rel="apple-touch-icon" href="${base}icon-192.png" />
<link rel="preload" href="${base}site.css" as="style" />
<link rel="stylesheet" href="${base}site.css" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="OpenKeys" />
<meta property="og:url" content="${url(page.slug)}" />
<meta property="og:title" content="${esc(page.h1)}" />
<meta property="og:description" content="${esc(page.description)}" />
<meta property="og:image" content="${SITE}/og-image.jpg" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${esc(page.h1)}" />
<meta name="twitter:description" content="${esc(page.description)}" />
<meta name="twitter:image" content="${SITE}/og-image.jpg" />
${jsonLd(page).map((d) => `<script type="application/ld+json">${JSON.stringify(d)}</script>`).join('\n')}
</head>
<body>
${header(page)}
<main><div class="wrap">
<p class="crumbs"><a href="${href('')}">OpenKeys</a>${them ? ` / <a href="${href('alternatives')}">Compare</a>` : ''} / ${esc(page.nav)}</p>
<section>
<h1 class="display">${esc(page.h1)}</h1>
<p class="lede">${esc(page.lede)}</p>
<div class="row"><a class="btn primary" href="${href('')}">Start playing</a><a class="btn" href="https://github.com/PaulJoshi/OpenKeys">View source</a></div>
<figure class="shot"><img src="${base}img/practice.jpg" width="1280" height="780" alt="OpenKeys practice screen: sheet music with notes marked right and wrong, falling notes and an on-screen piano keyboard" /><figcaption>Practising in OpenKeys: sheet music, falling notes and the keyboard, in a browser.</figcaption></figure>
</section>
<section>
${page.intro.map((p) => `<p>${esc(p)}</p>`).join('\n')}
</section>
${page.table ? `<section><h2>${them ? `OpenKeys and ${esc(them.name)} side by side` : 'At a glance'}</h2>${table(page.table)}</section>` : ''}
${them ? `<section><h2>When OpenKeys is the better fit</h2><ul class="plain">${page.better.map((b) => `<li>${esc(b)}</li>`).join('')}</ul></section>
<section><h2>When ${esc(them.name)} is the better fit</h2><ul class="plain">${page.worse.map((b) => `<li>${esc(b)}</li>`).join('')}</ul></section>` : ''}
${(page.sections ?? []).map((s) => `<section><h2>${esc(s.h2)}</h2>${s.html}</section>`).join('\n')}
<section><h2>What OpenKeys does</h2>${tiles()}</section>
<section><h2>Questions</h2>${faq(page.faq)}</section>
<section><h2>Try it now</h2><p>OpenKeys opens in your browser in a few seconds. No account, nothing to install.</p><div class="row"><a class="btn primary" href="${href('')}">Start playing</a></div></section>
<section><h2>More</h2>${related(page)}</section>
</div></main>
${footer()}
</body>
</html>
`;
}

for (const page of PAGES) {
const dir = join(dist, page.slug);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'index.html'), render(page));
}

const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${['', ...PAGES.map((p) => p.slug)].map((s) => ` <url><loc>${url(s)}</loc><lastmod>${UPDATED}</lastmod></url>`).join('\n')}
</urlset>
`;
writeFileSync(join(dist, 'sitemap.xml'), sitemap);
console.log(`build-site: ${PAGES.length} pages and sitemap.xml written to dist/`);
Loading
Loading