From da63507cd2e0ee4e453cbd55875fee0b8c708faa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:17:45 +0000 Subject: [PATCH] Fix Anthem melody render and share-link boot --- ventures/anthem/app.js | 882 ++++++++++++++++++++++------------------- 1 file changed, 481 insertions(+), 401 deletions(-) diff --git a/ventures/anthem/app.js b/ventures/anthem/app.js index f5c13ae..5d95619 100644 --- a/ventures/anthem/app.js +++ b/ventures/anthem/app.js @@ -1,412 +1,492 @@ -// Anthem - Main Application Logic +// 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. -// Audio context for WebAudio API -let audioContext; -let isPlaying = false; -let scheduledNotes = []; +(function () { + "use strict"; -// Style constants -const STYLES = { - CHIPTUNE: 'chiptune', - LOFI: 'lo-fi', - FANFARE: 'fanfare' -}; - -// Current state -let currentState = { - name: 'Anthem', - style: STYLES.CHIPTUNE, - volume: 0.7 -}; - -// Note mapping for melody generation -const NOTE_MAPPING = ['C4', 'D4', 'E4', 'F4', 'G4', 'A4', 'B4', 'C5']; -const OCTAVE_NOTES = { - 'C4': 261.63, - 'D4': 293.66, - 'E4': 329.63, - 'F4': 349.23, - 'G4': 392.00, - 'A4': 440.00, - 'B4': 493.88, - 'C5': 523.25 -}; - -// Initialize the app -function initApp() { - // Check if we have a saved state from URL - const loadedState = Time.load(); - if (loadedState) { - currentState = loadedState; - } - - // Set up UI elements - setupUI(); - - // Initialize audio context on first user interaction - document.body.addEventListener('click', initAudio, { once: true }); - - // Render initial state - renderState(); -} - -// Set up UI event listeners -function setupUI() { - const nameInput = document.getElementById('name-input'); - const playButton = document.getElementById('play-button'); - const styleChips = document.querySelectorAll('.style-chip'); - const shareButton = document.getElementById('share-button'); - const downloadButton = document.getElementById('download-button'); - - // Name input handler - nameInput.addEventListener('input', function(e) { - currentState.name = e.target.value || 'Anthem'; - renderState(); - }); - - // Play button handler - playButton.addEventListener('click', function() { - if (isPlaying) { - stopPlayback(); - playButton.textContent = 'Play'; - isPlaying = false; - } else { - playMelody(); - playButton.textContent = 'Stop'; - isPlaying = true; - } - }); - - // Style selection handlers - styleChips.forEach(chip => { - chip.addEventListener('click', function() { - // Remove active class from all chips - styleChips.forEach(c => c.classList.remove('active')); - // Add active class to clicked chip - this.classList.add('active'); - // Update style - currentState.style = this.dataset.style; - renderState(); - }); - }); - - // Share button handler - shareButton.addEventListener('click', function() { - const link = Time.save(currentState); - if (link) { - navigator.clipboard.writeText(link).then(() => { - const originalText = shareButton.textContent; - shareButton.textContent = 'Copied!'; - setTimeout(() => { - shareButton.textContent = originalText; - }, 2000); - }); - } else { - // Fallback for when Time.save returns null - const url = new URL(window.location); - url.searchParams.set('s', btoa(JSON.stringify(currentState)).replace(/[^a-zA-Z0-9]/g, '')); - navigator.clipboard.writeText(url.toString()); - const originalText = shareButton.textContent; - shareButton.textContent = 'Copied!'; - setTimeout(() => { - shareButton.textContent = originalText; - }, 2000); - } - }); - - // Download button handler - downloadButton.addEventListener('click', function() { - generateWAV(); - }); -} - -// Render current state to UI -function renderState() { - // Update name input - document.getElementById('name-input').value = currentState.name; - - // Update title card - document.getElementById('title-text').textContent = currentState.name; - - // Update style chips - document.querySelectorAll('.style-chip').forEach(chip => { - chip.classList.toggle('active', chip.dataset.style === currentState.style); - }); - - // Update visualization - updateVisualization(); -} - -// Update visualization based on current state -function updateVisualization() { - const viz = document.getElementById('visualization'); - viz.innerHTML = ''; - - // Create bars representing the melody - for (let i = 0; i < 8; i++) { - const bar = document.createElement('div'); - bar.className = 'bar'; - - // Get note for this position - const charIndex = i % currentState.name.length; - const charCode = currentState.name.charCodeAt(charIndex); - const noteIndex = charCode % NOTE_MAPPING.length; - const note = NOTE_MAPPING[noteIndex]; - - // Set height based on note frequency - const frequency = OCTAVE_NOTES[note]; - const height = 20 + (frequency / OCTAVE_NOTES['C5']) * 80; - bar.style.height = `${height}%`; - - // Set color based on style - switch (currentState.style) { - case STYLES.CHIPTUNE: - bar.style.backgroundColor = '#00ff00'; - break; - case STYLES.LOFI: - bar.style.backgroundColor = '#ff6b6b'; - break; - case STYLES.FANFARE: - bar.style.backgroundColor = '#4cc9f0'; - break; - } - - viz.appendChild(bar); - } -} - -// Initialize audio context -function initAudio() { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - } -} - -// Play the melody -function playMelody() { - if (!audioContext) { - initAudio(); - } - - if (audioContext.state === 'suspended') { - audioContext.resume(); - } - - stopPlayback(); - - // Schedule notes for playback - const startTime = audioContext.currentTime + 0.1; - const tempo = 120; // BPM - const secondsPerBeat = 60 / tempo; - - for (let i = 0; i < 8; i++) { - const charIndex = i % currentState.name.length; - const charCode = currentState.name.charCodeAt(charIndex); - const noteIndex = charCode % NOTE_MAPPING.length; - const note = NOTE_MAPPING[noteIndex]; - const frequency = OCTAVE_NOTES[note]; - - const noteTime = startTime + i * secondsPerBeat; - scheduleNote(frequency, noteTime, secondsPerBeat * 0.8); - } -} - -// Schedule a single note -function scheduleNote(frequency, startTime, duration) { - if (!audioContext) return; - - const oscillator = audioContext.createOscillator(); - const gainNode = audioContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(audioContext.destination); - - // Set oscillator type based on style - switch (currentState.style) { - case STYLES.CHIPTUNE: - oscillator.type = 'square'; - break; - case STYLES.LOFI: - oscillator.type = 'sawtooth'; - gainNode.gain.value = 0.3; // Lower volume for lo-fi - break; - case STYLES.FANFARE: - oscillator.type = 'triangle'; - break; - } - - oscillator.frequency.value = frequency; - - // Apply envelope - gainNode.gain.setValueAtTime(0, startTime); - gainNode.gain.linearRampToValueAtTime(currentState.volume, startTime + 0.01); - gainNode.gain.linearRampToValueAtTime(0, startTime + duration); - - oscillator.start(startTime); - oscillator.stop(startTime + duration); - - // Keep track of scheduled notes for stopping - scheduledNotes.push({ - oscillator: oscillator, - gainNode: gainNode - }); - - // Clean up after note finishes - oscillator.onended = function() { - const index = scheduledNotes.findIndex(note => note.oscillator === oscillator); - if (index !== -1) { - scheduledNotes.splice(index, 1); - } - - // If this was the last note, reset play button - if (scheduledNotes.length === 0) { - document.getElementById('play-button').textContent = 'Play'; - isPlaying = false; - } + 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" } }; -} -// Stop playback -function stopPlayback() { - scheduledNotes.forEach(note => { + 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 { - note.oscillator.stop(); + 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) { - // Ignore errors when stopping - } - }); - scheduledNotes = []; -} - -// Generate WAV file -function generateWAV() { - // Create audio context for offline rendering - const sampleRate = 44100; - const tempo = 120; // BPM - const secondsPerBeat = 60 / tempo; - const duration = 8 * secondsPerBeat; // 8 beats total - - const offlineContext = new (window.OfflineAudioContext || window.webkitOfflineAudioContext)( - 1, // mono - sampleRate * duration, - sampleRate - ); - - // Schedule notes for offline rendering - const startTime = 0.1; - - for (let i = 0; i < 8; i++) { - const charIndex = i % currentState.name.length; - const charCode = currentState.name.charCodeAt(charIndex); - const noteIndex = charCode % NOTE_MAPPING.length; - const note = NOTE_MAPPING[noteIndex]; - const frequency = OCTAVE_NOTES[note]; - - const noteTime = startTime + i * secondsPerBeat; - scheduleOfflineNote(offlineContext, frequency, noteTime, secondsPerBeat * 0.8); - } - - // Render the audio - offlineContext.startRendering().then(function(renderedBuffer) { - // Convert to WAV format - const wavBlob = audioBufferToWav(renderedBuffer); - - // Create download link - const url = URL.createObjectURL(wavBlob); - const a = document.createElement('a'); - a.href = url; - a.download = `${currentState.name}-theme-song.wav`; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); - }).catch(function(err) { - console.error('Error rendering audio:', err); - alert('Error generating WAV file'); - }); -} - -// Schedule a note for offline rendering -function scheduleOfflineNote(offlineContext, frequency, startTime, duration) { - const oscillator = offlineContext.createOscillator(); - const gainNode = offlineContext.createGain(); - - oscillator.connect(gainNode); - gainNode.connect(offlineContext.destination); - - // Set oscillator type based on style - switch (currentState.style) { - case STYLES.CHIPTUNE: - oscillator.type = 'square'; - break; - case STYLES.LOFI: - oscillator.type = 'sawtooth'; - gainNode.gain.value = 0.3; // Lower volume for lo-fi - break; - case STYLES.FANFARE: - oscillator.type = 'triangle'; - break; - } - - oscillator.frequency.value = frequency; - - // Apply envelope - gainNode.gain.setValueAtTime(0, startTime); - gainNode.gain.linearRampToValueAtTime(currentState.volume, startTime + 0.01); - gainNode.gain.linearRampToValueAtTime(0, startTime + duration); - - oscillator.start(startTime); - oscillator.stop(startTime + duration); -} - -// Convert AudioBuffer to WAV format -function audioBufferToWav(buffer) { - const length = buffer.length; - const numberOfChannels = buffer.numberOfChannels; - const sampleRate = buffer.sampleRate; - const arrayBuffer = new ArrayBuffer(44 + length * numberOfChannels * 2); - const view = new DataView(arrayBuffer); - - // Write WAV header - writeString(view, 0, 'RIFF'); - view.setUint32(4, 36 + length * numberOfChannels * 2, true); - writeString(view, 8, 'WAVE'); - writeString(view, 12, 'fmt '); - view.setUint32(16, 16, true); - view.setUint16(20, 1, true); - view.setUint16(22, numberOfChannels, true); - view.setUint32(24, sampleRate, true); - view.setUint32(28, sampleRate * numberOfChannels * 2, true); - view.setUint16(32, numberOfChannels * 2, true); - view.setUint16(34, 16, true); - writeString(view, 36, 'data'); - view.setUint32(40, length * numberOfChannels * 2, true); - - // Write PCM samples - const channelData = []; - for (let i = 0; i < numberOfChannels; i++) { - channelData[i] = buffer.getChannelData(i); - } - - let offset = 44; - for (let i = 0; i < length; i++) { - for (let channel = 0; channel < numberOfChannels; channel++) { - const sample = Math.max(-1, Math.min(1, channelData[channel][i])); - const int16 = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; - view.setInt16(offset, int16, true); - offset += 2; + return null; } } - - return new Blob([view], { type: 'audio/wav' }); -} -// Helper function to write string to DataView -function writeString(view, offset, string) { - for (let i = 0; i < string.length; i++) { - view.setUint8(offset + i, string.charCodeAt(i)); + function shareUrl() { + var url = new URL(window.location.href); + url.searchParams.set("s", encodeState(state)); + url.hash = ""; + return url.toString(); } -} -// Initialize when page loads -document.addEventListener('DOMContentLoaded', initApp); \ No newline at end of file + 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"); } + }; +})();