Crystal Roberts-Oddo

Piano educator and speaker

Portrait of Crystal Roberts-Oddo

For 40 years, I've run Creative Keyboards, my piano education studio. I am also the founder of Piano on the Spectrum, and I speak at music teacher conferences about teaching autistic students. This work is personal. I raised two autistic sons and taught them both to play piano.

(function () { var root = document.querySelector('.pb-contact'); if (!root) return; var strip = root.querySelector('.pb-keys'); var links = root.querySelector('.pb-links'); var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; /* ---- Sound: off until the visitor turns it on ---- */ var AudioCtx = window.AudioContext || window.webkitAudioContext; var audio = null; var master = null; var soundOn = false; function startAudio() { if (audio) return; audio = new AudioCtx(); var limiter = audio.createDynamicsCompressor(); master = audio.createGain(); master.gain.value = 0.6; master.connect(limiter); limiter.connect(audio.destination); } /* A soft, piano-like tone: quick attack, long decay, lower notes ring longer */ function playNote(freq) { if (!soundOn || !audio || !freq) return; var now = audio.currentTime; var length = Math.max(0.7, 2.4 - freq / 600); var env = audio.createGain(); var tone = audio.createBiquadFilter(); env.gain.setValueAtTime(0.0001, now); env.gain.exponentialRampToValueAtTime(0.3, now + 0.008); env.gain.exponentialRampToValueAtTime(0.0001, now + length); tone.type = 'lowpass'; tone.frequency.value = Math.min(7000, freq * 6); env.connect(tone); tone.connect(master); [[1, 'triangle', 1], [2, 'sine', 0.3], [3, 'sine', 0.1]].forEach(function (part) { var osc = audio.createOscillator(); var level = audio.createGain(); osc.type = part[1]; osc.frequency.value = freq * part[0]; level.gain.value = part[2]; osc.connect(level); level.connect(env); osc.start(now); osc.stop(now + length + 0.05); }); } /* ---- Piano key strip ---- */ var WHITE_STEPS = [0, 2, 4, 5, 7, 9, 11]; /* C D E F G A B */ var BLACK_AFTER = { 0: 1, 1: 1, 3: 1, 4: 1, 5: 1 }; /* C D F G A */ var count = 0; var current = null; var timers = []; /* The strip always starts on a C. Returns the note's frequency in hertz. */ function pitch(index, sharp, octave) { var midi = 12 * (octave + 1) + 12 * Math.floor(index / 7) + WHITE_STEPS[index % 7] + (sharp ? 1 : 0); return 440 * Math.pow(2, (midi - 69) / 12); } function build() { var n = Math.max(7, Math.round(strip.clientWidth / 30)); if (n === count) return; count = n; current = null; strip.textContent = ''; var octave = n > 21 ? 2 : n > 14 ? 3 : 4; /* keeps middle C on the strip at every width */ var i, key; for (i = 0; i < n; i++) { key = document.createElement('div'); key.className = 'pb-key'; key.setAttribute('data-freq', pitch(i, false, octave).toFixed(2)); strip.appendChild(key); } for (i = 0; i < n - 1; i++) { if (!BLACK_AFTER[i % 7]) continue; key = document.createElement('div'); key.className = 'pb-key pb-key--black'; key.setAttribute('data-freq', pitch(i, true, octave).toFixed(2)); key.style.left = ((i + 1) / n * 100) + '%'; key.style.width = (0.58 / n * 100) + '%'; strip.appendChild(key); } } function press(key) { if (key === current) return; if (current) current.classList.remove('is-down'); current = key; if (current) { current.classList.add('is-down'); playNote(parseFloat(current.getAttribute('data-freq'))); } } function keyAt(e) { var el = document.elementFromPoint(e.clientX, e.clientY); return el && el.parentNode === strip ? el : null; } function stopGlissando() { while (timers.length) clearTimeout(timers.pop()); var down = strip.querySelectorAll('.is-down'); for (var i = 0; i < down.length; i++) down[i].classList.remove('is-down'); } /* One run up the white keys, to show the strip responds */ function glissando() { var whites = strip.querySelectorAll('.pb-key:not(.pb-key--black)'); Array.prototype.forEach.call(whites, function (key, i) { timers.push(setTimeout(function () { key.classList.add('is-down'); }, 300 + i * 38)); timers.push(setTimeout(function () { key.classList.remove('is-down'); }, 300 + i * 38 + 90)); }); } if (strip) { build(); if ('ResizeObserver' in window) new ResizeObserver(build).observe(strip); else window.addEventListener('resize', build); strip.addEventListener('pointerdown', function (e) { stopGlissando(); press(null); press(keyAt(e)); }); strip.addEventListener('pointermove', function (e) { if (timers.length) stopGlissando(); press(keyAt(e)); }); ['pointerup', 'pointerleave', 'pointercancel'].forEach(function (type) { strip.addEventListener(type, function (e) { if (type === 'pointerup' && e.pointerType === 'mouse') return; press(null); }); }); /* Sound switch, added above the keys */ if (AudioCtx) { var toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = 'pb-sound'; toggle.setAttribute('aria-pressed', 'false'); toggle.textContent = 'Turn sound on to play the keys'; toggle.addEventListener('click', function () { startAudio(); soundOn = !soundOn; if (soundOn) audio.resume(); toggle.setAttribute('aria-pressed', soundOn ? 'true' : 'false'); toggle.textContent = soundOn ? 'Turn sound off' : 'Turn sound on to play the keys'; }); strip.parentNode.insertBefore(toggle, strip); } } /* ---- Scroll cue: jumps to the links, then steps aside once they have been seen ---- */ var cue = root.querySelector('.pb-scroll'); if (cue && links) { cue.addEventListener('click', function () { links.scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' }); }); if ('IntersectionObserver' in window) { var cueWatch = new IntersectionObserver(function (entries) { if (!entries[0].isIntersecting) return; cueWatch.disconnect(); cue.classList.add('is-done'); }, { threshold: 0.5 }); cueWatch.observe(links); } } /* ---- Load sequence: runs once, when each part scrolls into view ---- */ if (!calm && 'IntersectionObserver' in window) { root.classList.add('pb-anim'); var seen = new IntersectionObserver(function (entries) { entries.forEach(function (entry) { if (!entry.isIntersecting) return; seen.unobserve(entry.target); if (entry.target === links) links.classList.add('pb-in'); else glissando(); }); }, { threshold: 0.3 }); if (links) seen.observe(links); if (strip) seen.observe(strip); } })();