Fix Anthem melody render and share-link boot
This commit is contained in:
1 parent
3c947ef392
commit
da63507cd2
1 file changed
+481
-401
+481
-401
@@ -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);
|
||||
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"); }
|
||||
};
|
||||
})();
|
||||
Reference in new issue
Block a user