// Anthem — name to an 8-bar theme, synthesized in the browser. // The preloaded sample and a typed name share one path: seed a melody, paint // the title card, and (when the user presses play) schedule the same notes. (function () { "use strict"; var STYLES = { chiptune: { wave: "square", gain: 0.22, filter: 1800, q: 0.7, detune: 0, attack: 0.005, release: 0.04, color: "#7CFF6B" }, "lo-fi": { wave: "triangle", gain: 0.28, filter: 900, q: 0.6, detune: 7, attack: 0.04, release: 0.18, color: "#F2A7C3" }, fanfare: { wave: "sawtooth", gain: 0.16, filter: 2400, q: 0.8, detune: 4, attack: 0.01, release: 0.12, color: "#F6C945" } }; var SCALE = [0, 2, 4, 5, 7, 9, 11]; var SAMPLE_NAME = "Maya Chen"; var BARS = 8; var BEATS = 4; var STEPS = BARS * BEATS; var audioContext = null; var isPlaying = false; var scheduled = []; var vizTimer = 0; var state = { name: SAMPLE_NAME, style: "chiptune" }; function $(id) { return document.getElementById(id); } function hashString(str) { var h = 2166136261; var s = String(str || ""); for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; } function mulberry32(seed) { var a = seed >>> 0; return function () { a |= 0; a = (a + 0x6D2B79F5) | 0; var t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } function clampName(value) { var name = String(value == null ? "" : value).replace(/\s+/g, " ").trim(); if (!name) return ""; return name.slice(0, 48); } // Letters seed the melody. Same name + style always yields the same notes. function compose(name, style) { var clean = clampName(name) || SAMPLE_NAME; var key = STYLES[style] ? style : "chiptune"; var seed = hashString(clean.toLowerCase() + "|" + key); var rand = mulberry32(seed); var root = 60 + (seed % 7); var motif = []; var i; for (i = 0; i < 4; i++) { var degree = SCALE[Math.floor(rand() * SCALE.length)]; var octave = rand() < 0.22 ? 12 : rand() < 0.12 ? -12 : 0; motif.push(root + degree + octave); } // A name with more letters leans the motif toward those letter degrees, // so "Maya" and "Maya Chen" are related but not identical. for (i = 0; i < clean.length && i < 4; i++) { var code = clean.toLowerCase().charCodeAt(i); if (code >= 97 && code <= 122) { motif[i % 4] = root + SCALE[(code - 97) % SCALE.length] + (i > 6 ? 12 : 0); } } var notes = []; for (i = 0; i < STEPS; i++) { var bar = Math.floor(i / BEATS); var beat = i % BEATS; var midi = motif[beat]; if (bar === 1 || bar === 5) midi += bar % 2 === 1 ? 2 : 0; if (bar >= 4) midi += rand() < 0.35 ? 12 : 0; if (bar === 6 && beat === 3) midi += 5; if (bar === 7 && beat === 3) midi = root + 12; if (key === "fanfare" && beat === 0) midi += 7; if (key === "lo-fi" && beat === 2) midi -= rand() < 0.5 ? 0 : 2; var rest = key === "lo-fi" ? rand() < 0.18 : rand() < 0.08; notes.push({ midi: midi, freq: midiToFreq(midi), rest: rest && beat !== 0, beat: beat, bar: bar }); } return { name: clean, style: key, seed: seed, notes: notes }; } function activeStyle() { return STYLES[state.style] ? state.style : "chiptune"; } function song() { return compose(state.name, activeStyle()); } function ensureAudio() { if (!audioContext) { var Ctx = window.AudioContext || window.webkitAudioContext; if (!Ctx) return null; audioContext = new Ctx(); } if (audioContext.state === "suspended") { audioContext.resume(); } return audioContext; } function stopPlayback() { scheduled.forEach(function (node) { try { node.stop(); } catch (e) { /* already stopped */ } }); scheduled = []; if (vizTimer) { clearInterval(vizTimer); vizTimer = 0; } document.querySelectorAll("#visualization .bar").forEach(function (bar) { bar.classList.remove("on"); }); } function setPlaying(on) { isPlaying = on; var btn = $("play-button"); if (btn) btn.textContent = on ? "Stop" : "Play"; var card = document.querySelector(".title-card"); if (card) card.classList.toggle("is-playing", on); } function voice(ctx, dest, freq, when, dur, styleKey) { var spec = STYLES[styleKey] || STYLES.chiptune; var osc = ctx.createOscillator(); var osc2 = ctx.createOscillator(); var gain = ctx.createGain(); var filter = ctx.createBiquadFilter(); filter.type = "lowpass"; filter.frequency.setValueAtTime(spec.filter, when); filter.Q.setValueAtTime(spec.q, when); osc.type = spec.wave; osc2.type = spec.wave; osc.frequency.setValueAtTime(freq, when); osc2.frequency.setValueAtTime(freq, when); osc2.detune.setValueAtTime(spec.detune, when); osc.connect(filter); osc2.connect(filter); filter.connect(gain); gain.connect(dest); var peak = spec.gain; gain.gain.setValueAtTime(0.0001, when); gain.gain.exponentialRampToValueAtTime(peak, when + spec.attack); gain.gain.exponentialRampToValueAtTime(0.0001, when + Math.max(spec.attack + 0.02, dur)); osc.start(when); osc2.start(when); osc.stop(when + dur + 0.02); osc2.stop(when + dur + 0.02); return osc; } function scheduleInto(ctx, dest, piece, startTime, secondsPerBeat) { var nodes = []; piece.notes.forEach(function (note, i) { if (note.rest) return; var when = startTime + i * secondsPerBeat; var dur = secondsPerBeat * (piece.style === "lo-fi" ? 0.92 : 0.72); if (piece.style === "fanfare" && note.beat === 0) dur = secondsPerBeat * 0.95; nodes.push(voice(ctx, dest, note.freq, when, dur, piece.style)); }); return nodes; } function pulseBars(piece) { var bars = document.querySelectorAll("#visualization .bar"); var step = 0; var tempo = piece.style === "fanfare" ? 132 : piece.style === "lo-fi" ? 86 : 120; var ms = (60 / tempo) * 1000; if (vizTimer) clearInterval(vizTimer); vizTimer = setInterval(function () { bars.forEach(function (bar, i) { bar.classList.toggle("on", i === step % bars.length); }); step += 1; if (step >= piece.notes.length) { clearInterval(vizTimer); vizTimer = 0; bars.forEach(function (bar) { bar.classList.remove("on"); }); setPlaying(false); } }, ms); } function playMelody() { var ctx = ensureAudio(); if (!ctx) return; stopPlayback(); var piece = song(); var tempo = piece.style === "fanfare" ? 132 : piece.style === "lo-fi" ? 86 : 120; var secondsPerBeat = 60 / tempo; scheduled = scheduleInto(ctx, ctx.destination, piece, ctx.currentTime + 0.05, secondsPerBeat); setPlaying(true); pulseBars(piece); var last = scheduled[scheduled.length - 1]; if (last) { last.onended = function () { if (scheduled.indexOf(last) !== -1) setPlaying(false); }; } } function renderOutput(piece) { var title = $("title-text"); if (title) title.textContent = piece.name; var input = $("name-input"); if (input && input.value !== piece.name) input.value = piece.name; document.querySelectorAll(".style-chip").forEach(function (chip) { chip.classList.toggle("active", chip.dataset.style === piece.style); chip.setAttribute("aria-pressed", chip.dataset.style === piece.style ? "true" : "false"); }); var viz = $("visualization"); if (!viz) return; viz.innerHTML = ""; viz.setAttribute("aria-label", "Melody for " + piece.name); var min = 999; var max = 0; piece.notes.forEach(function (n) { if (n.midi < min) min = n.midi; if (n.midi > max) max = n.midi; }); var span = Math.max(1, max - min); var color = (STYLES[piece.style] || STYLES.chiptune).color; piece.notes.forEach(function (note, i) { var bar = document.createElement("div"); bar.className = "bar" + (note.rest ? " rest" : ""); var height = note.rest ? 8 : 18 + ((note.midi - min) / span) * 78; bar.style.height = height + "%"; bar.style.backgroundColor = note.rest ? "rgba(255,255,255,0.18)" : color; bar.title = note.rest ? "rest" : "bar " + (note.bar + 1) + " beat " + (note.beat + 1); bar.dataset.step = String(i); viz.appendChild(bar); }); var caption = $("melody-caption"); if (caption) { var sounding = piece.notes.filter(function (n) { return !n.rest; }).length; caption.textContent = piece.name + " · " + piece.style + " · " + BARS + " bars · " + sounding + " notes"; } } function applyState(next, pushUrl) { var name = clampName(next && next.name) || SAMPLE_NAME; var style = next && STYLES[next.style] ? next.style : "chiptune"; state.name = name; state.style = style; renderOutput(song()); if (pushUrl) writeUrl(); } function encodeState(obj) { var json = JSON.stringify({ name: obj.name, style: obj.style }); return btoa(unescape(encodeURIComponent(json))) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/g, ""); } function decodeState(token) { try { var b64 = String(token).replace(/-/g, "+").replace(/_/g, "/"); while (b64.length % 4) b64 += "="; var json = decodeURIComponent(escape(atob(b64))); var obj = JSON.parse(json); if (!obj || typeof obj !== "object") return null; if (!clampName(obj.name)) return null; return { name: clampName(obj.name), style: STYLES[obj.style] ? obj.style : "chiptune" }; } catch (e) { return null; } } function shareUrl() { var url = new URL(window.location.href); url.searchParams.set("s", encodeState(state)); url.hash = ""; return url.toString(); } function writeUrl() { try { var url = new URL(window.location.href); url.searchParams.set("s", encodeState(state)); window.history.replaceState({}, "", url); } catch (e) { /* file:// or a locked history */ } } function readUrl() { var params = new URLSearchParams(window.location.search); var token = params.get("s"); if (!token) return null; return decodeState(token); } function copyText(text) { if (navigator.clipboard && navigator.clipboard.writeText) { return navigator.clipboard.writeText(text); } return new Promise(function (resolve, reject) { var input = document.createElement("textarea"); input.value = text; input.setAttribute("readonly", ""); input.style.position = "fixed"; input.style.left = "-9999px"; document.body.appendChild(input); input.select(); try { document.execCommand("copy") ? resolve() : reject(new Error("copy failed")); } catch (e) { reject(e); } document.body.removeChild(input); }); } function flash(button, label) { var original = button.textContent; button.textContent = label; setTimeout(function () { button.textContent = original; }, 1600); } function audioBufferToWav(buffer) { var channels = buffer.numberOfChannels; var rate = buffer.sampleRate; var length = buffer.length; var bytesPerSample = 2; var blockAlign = channels * bytesPerSample; var dataSize = length * blockAlign; var view = new DataView(new ArrayBuffer(44 + dataSize)); function str(offset, s) { for (var i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i)); } str(0, "RIFF"); view.setUint32(4, 36 + dataSize, true); str(8, "WAVE"); str(12, "fmt "); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, channels, true); view.setUint32(24, rate, true); view.setUint32(28, rate * blockAlign, true); view.setUint16(32, blockAlign, true); view.setUint16(34, 16, true); str(36, "data"); view.setUint32(40, dataSize, true); var channelData = []; for (var c = 0; c < channels; c++) channelData.push(buffer.getChannelData(c)); var offset = 44; for (var i = 0; i < length; i++) { for (c = 0; c < channels; c++) { var sample = Math.max(-1, Math.min(1, channelData[c][i])); view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true); offset += 2; } } return new Blob([view], { type: "audio/wav" }); } function downloadWav() { var piece = song(); var tempo = piece.style === "fanfare" ? 132 : piece.style === "lo-fi" ? 86 : 120; var secondsPerBeat = 60 / tempo; var duration = piece.notes.length * secondsPerBeat + 0.4; var rate = 44100; var Offline = window.OfflineAudioContext || window.webkitOfflineAudioContext; if (!Offline) return; var offline = new Offline(1, Math.ceil(rate * duration), rate); scheduleInto(offline, offline.destination, piece, 0.05, secondsPerBeat); offline.startRendering().then(function (buffer) { var blob = audioBufferToWav(buffer); var url = URL.createObjectURL(blob); var a = document.createElement("a"); var slug = piece.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "anthem"; a.href = url; a.download = slug + "-" + piece.style + ".wav"; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function () { URL.revokeObjectURL(url); }, 2000); }).catch(function (err) { console.error(err); }); } function bind() { var input = $("name-input"); var play = $("play-button"); var share = $("share-button"); var download = $("download-button"); input.addEventListener("input", function () { state.name = clampName(input.value) || SAMPLE_NAME; renderOutput(song()); writeUrl(); }); input.addEventListener("keydown", function (e) { if (e.key === "Enter") { e.preventDefault(); play.click(); } }); play.addEventListener("click", function () { if (isPlaying) { stopPlayback(); setPlaying(false); } else { playMelody(); } }); document.querySelectorAll(".style-chip").forEach(function (chip) { chip.addEventListener("click", function () { state.style = STYLES[chip.dataset.style] ? chip.dataset.style : "chiptune"; renderOutput(song()); writeUrl(); if (isPlaying) playMelody(); }); }); share.addEventListener("click", function () { var link = shareUrl(); writeUrl(); copyText(link).then(function () { flash(share, "Copied!"); }).catch(function () { flash(share, "Copy the link"); window.prompt("Share link", link); }); if (window.Time && typeof window.Time.save === "function") { try { window.Time.save({ name: state.name, style: state.style }); } catch (e) { /* extra only */ } } }); download.addEventListener("click", downloadWav); } function boot() { bind(); var fromUrl = readUrl(); if (fromUrl) { applyState(fromUrl, false); return; } // Time.load() is a promise of a shared object, or null. Never treat the // promise itself as the song. First visit has no share token, so the // sample name goes through compose() and paints the real melody. var pending = window.Time && typeof window.Time.load === "function" ? window.Time.load() : null; Promise.resolve(pending).then(function (loaded) { if (loaded && typeof loaded === "object" && clampName(loaded.name)) { applyState({ name: loaded.name, style: loaded.style }, true); return; } applyState({ name: SAMPLE_NAME, style: "chiptune" }, false); }).catch(function () { applyState({ name: SAMPLE_NAME, style: "chiptune" }, false); }); } document.addEventListener("DOMContentLoaded", boot); window.Anthem = { compose: compose, sample: function () { return compose(SAMPLE_NAME, "chiptune"); } }; })();