Add web player — HTML5 audio, oscilloscope, SEO, share links

Static site with nginx on fly.io. Features:
- Album art, track list with descriptions and metadata
- Persistent bottom player bar with seek, volume, next/prev
- Real-time oscilloscope via Web Audio API
- Deep linking via URL hash (#track_name)
- Share link per track (copy to clipboard)
- Keyboard shortcuts (space, arrows, n/p)
- Mobile responsive
- SEO meta tags, Open Graph, Twitter cards
- Streaming service link placeholders
- MP3s converted from WAVs for web delivery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 00:39:40 -04:00
parent c1b4a11d51
commit b6009f9a11
4 changed files with 629 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY site/ /usr/share/nginx/html/
EXPOSE 80
+15
View File
@@ -0,0 +1,15 @@
app = "interpretations"
primary_region = "iad"
[build]
[http_service]
internal_port = 80
force_https = true
auto_stop_machines = "stop"
auto_start_machines = true
min_machines_running = 0
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+611
View File
@@ -0,0 +1,611 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interpretations — Infinite State</title>
<meta name="description" content="A 24-track album written entirely in Python. Indian classical ragas, trap beats, ambient drones, acid bass, mellotron tape, and raw synthesis — every sound generated from code using pytheory.">
<meta name="keywords" content="Infinite State, Interpretations, pytheory, Python music, algorithmic composition, sitar, mellotron, 808, electronic music, Indian classical, ambient, trap">
<meta name="author" content="Infinite State">
<meta property="og:title" content="Interpretations — Infinite State">
<meta property="og:description" content="24 tracks written in Python. Sitar meets 808. Code becomes music.">
<meta property="og:image" content="cover.png">
<meta property="og:type" content="music.album">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Interpretations — Infinite State">
<meta name="twitter:description" content="24 tracks written in Python. Sitar meets 808. Code becomes music.">
<meta name="twitter:image" content="cover.png">
<link rel="icon" type="image/png" href="cover.png">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
min-height: 100vh;
padding-bottom: 100px;
}
/* Header */
.header {
text-align: center;
padding: 40px 20px 20px;
}
.cover {
width: 320px;
height: 320px;
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0,0,0,0.6);
margin-bottom: 20px;
}
.album-title {
font-size: 28px;
font-weight: 700;
color: #fff;
letter-spacing: 1px;
}
.artist {
font-size: 16px;
color: #888;
margin-top: 4px;
}
.album-info {
font-size: 13px;
color: #666;
margin-top: 8px;
}
/* Track list */
.tracklist {
max-width: 700px;
margin: 30px auto;
padding: 0 20px;
}
.track {
display: flex;
align-items: center;
padding: 12px 16px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
gap: 14px;
}
.track:hover {
background: #1a1a1a;
}
.track.active {
background: #1a1a1a;
}
.track.playing {
background: #161a12;
}
.track-num {
font-size: 14px;
color: #555;
width: 28px;
text-align: right;
flex-shrink: 0;
}
.track.playing .track-num {
color: #c8a846;
}
.track-info {
flex: 1;
min-width: 0;
}
.track-title {
font-size: 15px;
font-weight: 500;
color: #ddd;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track.playing .track-title {
color: #c8a846;
}
.track-meta {
font-size: 12px;
color: #666;
margin-top: 2px;
}
.track-duration {
font-size: 13px;
color: #555;
flex-shrink: 0;
}
/* Player bar */
.player {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #141414;
border-top: 1px solid #222;
padding: 12px 20px;
display: flex;
align-items: center;
gap: 16px;
z-index: 100;
backdrop-filter: blur(10px);
}
.player-cover {
width: 48px;
height: 48px;
border-radius: 4px;
flex-shrink: 0;
}
.player-info {
flex-shrink: 0;
width: 180px;
}
.player-title {
font-size: 14px;
font-weight: 500;
color: #fff;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.player-artist {
font-size: 12px;
color: #888;
}
.player-controls {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.player-btn {
background: none;
border: none;
color: #ccc;
font-size: 20px;
cursor: pointer;
padding: 4px;
transition: color 0.15s;
}
.player-btn:hover {
color: #fff;
}
.player-btn.play-btn {
font-size: 28px;
color: #fff;
}
.player-progress {
flex: 1;
display: flex;
align-items: center;
gap: 10px;
}
.player-time {
font-size: 12px;
color: #888;
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.progress-bar {
flex: 1;
height: 4px;
background: #333;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-bar:hover {
height: 6px;
}
.progress-fill {
height: 100%;
background: #c8a846;
border-radius: 2px;
width: 0%;
transition: width 0.1s linear;
}
.player-volume {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.volume-bar {
width: 80px;
height: 4px;
background: #333;
border-radius: 2px;
cursor: pointer;
}
.volume-fill {
height: 100%;
background: #888;
border-radius: 2px;
width: 80%;
}
/* Oscilloscope */
.visualizer-wrap {
max-width: 700px;
margin: 0 auto;
padding: 0 20px;
}
#oscilloscope {
width: 100%;
height: 80px;
display: block;
}
/* Streaming links */
.streaming-links {
margin-top: 16px;
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.stream-btn {
display: inline-block;
padding: 8px 18px;
border: 1px solid #333;
border-radius: 20px;
color: #aaa;
text-decoration: none;
font-size: 13px;
transition: all 0.15s;
}
.stream-btn:hover {
border-color: #c8a846;
color: #c8a846;
}
/* Track description */
.track-desc {
font-size: 12px;
color: #555;
margin-top: 2px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.track.playing .track-desc {
color: #887a3a;
}
/* Share link */
.track-share {
font-size: 12px;
color: #444;
cursor: pointer;
flex-shrink: 0;
padding: 4px;
transition: color 0.15s;
}
.track-share:hover {
color: #c8a846;
}
/* Responsive */
@media (max-width: 640px) {
.cover { width: 240px; height: 240px; }
.player-info { width: 120px; }
.player-volume { display: none; }
}
</style>
</head>
<body>
<div class="header">
<img src="cover.png" alt="Interpretations" class="cover">
<div class="album-title">Interpretations</div>
<div class="artist">Infinite State</div>
<div class="album-info">24 tracks · Written in Python · Generated from code</div>
<div class="streaming-links" id="streaming-links">
<!-- Uncomment as links go live
<a href="#" class="stream-btn">Spotify</a>
<a href="#" class="stream-btn">Apple Music</a>
<a href="#" class="stream-btn">Bandcamp</a>
<a href="#" class="stream-btn">YouTube Music</a>
-->
<a href="https://github.com/kennethreitz/interpretations" class="stream-btn">GitHub</a>
</div>
</div>
<div class="visualizer-wrap">
<canvas id="oscilloscope" width="700" height="80"></canvas>
</div>
<div class="tracklist" id="tracklist"></div>
<div class="player" id="player" style="display:none">
<img src="cover.png" alt="" class="player-cover">
<div class="player-info">
<div class="player-title" id="player-title"></div>
<div class="player-artist">Infinite State</div>
</div>
<div class="player-controls">
<button class="player-btn" onclick="prevTrack()"></button>
<button class="player-btn play-btn" id="play-btn" onclick="togglePlay()"></button>
<button class="player-btn" onclick="nextTrack()"></button>
</div>
<div class="player-progress">
<span class="player-time" id="time-current">0:00</span>
<div class="progress-bar" id="progress-bar" onclick="seek(event)">
<div class="progress-fill" id="progress-fill"></div>
</div>
<span class="player-time" id="time-total">0:00</span>
</div>
<div class="player-volume">
<span style="color:#888;font-size:14px">🔊</span>
<div class="volume-bar" onclick="setVolume(event)">
<div class="volume-fill" id="volume-fill"></div>
</div>
</div>
</div>
<script>
const TRACKS = [
{ num: 1, file: "01_raga_midnight", title: "Raga Midnight", key: "D phr", bpm: 90, tuning: "shruti · 432Hz", desc: "Sitar raga in four movements with 808 drop. Tabla, tambura, dhol." },
{ num: 2, file: "02_shruti_lofi", title: "Shruti Lofi", key: "Dm", bpm: 75, tuning: "shruti", desc: "Microtonal lo-fi hip hop. Kalimba, Rhodes, sitar hook, mellotron flute." },
{ num: 3, file: "03_ghost_protocol", title: "Ghost Protocol", key: "Fm", bpm: 128, tuning: "", desc: "Trip-hop into trance build. Drift arp, kick at bar 49, NES melody at the peak." },
{ num: 4, file: "04_silk_road", title: "Silk Road", key: "Dm", bpm: 95, tuning: "", desc: "A caravan picking up musicians. Koto → sitar → mandolin → guitar → all together." },
{ num: 5, file: "05_the_observatory", title: "The Observatory", key: "Gm", bpm: 112, tuning: "", desc: "Chapel through shortwave static. Organ, choir, saw arp, theremin signal." },
{ num: 6, file: "06_acid_reign", title: "Acid Reign", key: "Am", bpm: 140, tuning: "", desc: "Dual 303 acid bass with resonant filter sweeps. Cajon, Rhodes, 808 sub." },
{ num: 7, file: "07_beast_mode", title: "Beast Mode", key: "Gm", bpm: 135, tuning: "", desc: "Trap drums, 808 slides, sitar hook + shred solo, mellotron flute drop." },
{ num: 8, file: "08_apex", title: "Apex", key: "Ebm", bpm: 140, tuning: "", desc: "The fastest, the hardest. Koto hook, wavefold bass, timpani, 32nd shreds." },
{ num: 9, file: "09_voltage", title: "Voltage", key: "Fm", bpm: 138, tuning: "", desc: "Raw oscillators. Sine, saw, square, hard_sync. Rhythm is pitch." },
{ num: 10, file: "10_an_exception_occurred", title: "An Exception Occurred", key: "Eb", bpm: 80, tuning: "", desc: "Stability → seeking → psychosis → despair → hymn → recovery. Every note by hand." },
{ num: 11, file: "11_voices", title: "Voices", key: "F#m", bpm: 65, tuning: "", desc: "Five vocal parts multiplying. Piano enters as reality. One last whisper." },
{ num: 12, file: "12_intrusive", title: "Intrusive", key: "Bbm", bpm: 92, tuning: "", desc: "One wavefold phrase repeating. Rhodes fights it. Drums fight it. It passes." },
{ num: 13, file: "13_gravity", title: "Gravity", key: "Cm", bpm: 88, tuning: "", desc: "Sparse piano, 808, boom bap. Sitar bend in the breakdown. Eastern touches." },
{ num: 14, file: "14_the_interruption", title: "The Interruption", key: "Dm", bpm: 85, tuning: "", desc: "String quartet ambushed by DnB. Flute, harp, drift reese. The strings win." },
{ num: 15, file: "15_sleight_of_hand", title: "Sleight of Hand", key: "Dm", bpm: 100, tuning: "", desc: "Nine genre shifts. Music box → didge → jazz → 808 → theremin → choir → acid 303." },
{ num: 16, file: "16_waveforms", title: "Waveforms", key: "Fm", bpm: 118, tuning: "", desc: "Synth showcase. Percussive blips stacking. FM solo, duet in thirds, canon." },
{ num: 17, file: "17_emergence", title: "Emergence", key: "Em", bpm: 100, tuning: "", desc: "Singing bowls + sitar arps birth synths. Both worlds collide at the peak." },
{ num: 18, file: "18_chakra", title: "Chakra", key: "(multi)", bpm: "60→135", tuning: "shruti · 432Hz", desc: "Root to crown journey. Seven chakras, metric modulation, drift at the crown." },
{ num: 19, file: "19_the_temple", title: "The Temple", key: "A phr", bpm: 65, tuning: "shruti · 432Hz", desc: "Devotional layers in a vast stone chamber. The reverb is the instrument." },
{ num: 20, file: "20_the_dialogue", title: "The Dialogue", key: "E phr", bpm: 75, tuning: "shruti · 432Hz", desc: "Sitar (human) and theremin (machine) find each other. House beat arrives." },
{ num: 21, file: "21_cathedral", title: "Cathedral", key: "Dm", bpm: 60, tuning: "", desc: "Tubular bells, bagpipe drone, mellotron choir, timpani thunder, pipe organ." },
{ num: 22, file: "22_tape_memory", title: "Tape Memory", key: "Dbm", bpm: 90, tuning: "", desc: "Mellotron flute dreams. FM, drift, crotales, hard_sync, PWM, wavefold, ring_mod." },
{ num: 23, file: "23_music_box_factory", title: "Music Box Factory", key: "G", bpm: 108, tuning: "", desc: "Eight tuned percussion instruments. Kalimba, vibraphone, celesta, marimba." },
{ num: 24, file: "24_deep_time", title: "Deep Time", key: "Bm", bpm: 40, tuning: "just", desc: "7.5 min ambient drone. Tingsha, singing bowls, didgeridoo, theremin, cello." },
];
const audio = new Audio();
audio.crossOrigin = 'anonymous';
let currentTrack = -1;
let isPlaying = false;
// Web Audio API — oscilloscope
let audioCtx, analyser, source, dataArray;
const canvas = document.getElementById('oscilloscope');
const ctx = canvas.getContext('2d');
function initAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
source = audioCtx.createMediaElementSource(audio);
source.connect(analyser);
analyser.connect(audioCtx.destination);
dataArray = new Uint8Array(analyser.frequencyBinCount);
drawOscilloscope();
}
function drawOscilloscope() {
requestAnimationFrame(drawOscilloscope);
if (!analyser) return;
const w = canvas.width = canvas.offsetWidth * 2;
const h = canvas.height = 160;
analyser.getByteTimeDomainData(dataArray);
ctx.fillStyle = '#0a0a0a';
ctx.fillRect(0, 0, w, h);
ctx.lineWidth = 2;
ctx.strokeStyle = '#c8a846';
ctx.beginPath();
const sliceWidth = w / dataArray.length;
let x = 0;
for (let i = 0; i < dataArray.length; i++) {
const v = dataArray[i] / 128.0;
const y = v * h / 2;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
x += sliceWidth;
}
ctx.lineTo(w, h / 2);
ctx.stroke();
}
// Build track list
const tracklist = document.getElementById('tracklist');
const BASE_URL = 'https://interpretations.kennethreitz.org';
TRACKS.forEach((t, i) => {
const meta = [t.key, t.bpm + ' BPM', t.tuning].filter(Boolean).join(' · ');
const el = document.createElement('div');
el.className = 'track';
el.id = `track-${i}`;
el.setAttribute('data-slug', t.file.replace(/^\d+_/, ''));
el.innerHTML = `
<span class="track-num">${t.num}</span>
<div class="track-info">
<div class="track-title">${t.title}</div>
<div class="track-meta">${meta}</div>
<div class="track-desc">${t.desc}</div>
</div>
<span class="track-share" title="Copy link" onclick="event.stopPropagation(); copyTrackLink(${i})">🔗</span>
<span class="track-duration" id="dur-${i}">—</span>
`;
el.onclick = () => playTrack(i);
tracklist.appendChild(el);
});
function copyTrackLink(i) {
const slug = TRACKS[i].file.replace(/^\d+_/, '');
const url = `${BASE_URL}/#${slug}`;
navigator.clipboard.writeText(url).then(() => {
const el = document.querySelectorAll('.track-share')[i];
el.textContent = '✓';
setTimeout(() => el.textContent = '🔗', 1500);
});
}
function formatTime(s) {
if (isNaN(s)) return '0:00';
const m = Math.floor(s / 60);
const sec = Math.floor(s % 60);
return `${m}:${sec.toString().padStart(2, '0')}`;
}
function playTrack(index) {
if (currentTrack >= 0) {
document.getElementById(`track-${currentTrack}`).classList.remove('playing');
}
currentTrack = index;
const t = TRACKS[index];
audio.src = `mp3s/${t.file}.mp3`;
initAudio();
audio.play();
isPlaying = true;
document.getElementById(`track-${index}`).classList.add('playing');
document.getElementById('player').style.display = 'flex';
document.getElementById('player-title').textContent = t.title;
document.getElementById('play-btn').textContent = '⏸';
}
function togglePlay() {
if (currentTrack < 0) return;
if (isPlaying) {
audio.pause();
isPlaying = false;
document.getElementById('play-btn').textContent = '▶';
} else {
audio.play();
isPlaying = true;
document.getElementById('play-btn').textContent = '⏸';
}
}
function nextTrack() {
if (currentTrack < TRACKS.length - 1) {
playTrack(currentTrack + 1);
}
}
function prevTrack() {
if (audio.currentTime > 3) {
audio.currentTime = 0;
} else if (currentTrack > 0) {
playTrack(currentTrack - 1);
}
}
function seek(e) {
const bar = document.getElementById('progress-bar');
const pct = e.offsetX / bar.offsetWidth;
audio.currentTime = pct * audio.duration;
}
function setVolume(e) {
const bar = e.currentTarget;
const pct = e.offsetX / bar.offsetWidth;
audio.volume = Math.max(0, Math.min(1, pct));
document.getElementById('volume-fill').style.width = (pct * 100) + '%';
}
// Auto-advance
audio.addEventListener('ended', () => {
if (currentTrack < TRACKS.length - 1) {
playTrack(currentTrack + 1);
} else {
isPlaying = false;
document.getElementById('play-btn').textContent = '▶';
}
});
// Progress updates
audio.addEventListener('timeupdate', () => {
document.getElementById('time-current').textContent = formatTime(audio.currentTime);
document.getElementById('time-total').textContent = formatTime(audio.duration);
const pct = (audio.currentTime / audio.duration) * 100;
document.getElementById('progress-fill').style.width = pct + '%';
});
// Load durations
audio.addEventListener('loadedmetadata', () => {
if (currentTrack >= 0) {
document.getElementById(`dur-${currentTrack}`).textContent = formatTime(audio.duration);
}
});
// Deep link — play track from URL hash
function checkHash() {
const hash = window.location.hash.slice(1);
if (hash) {
const idx = TRACKS.findIndex(t => t.file.replace(/^\d+_/, '') === hash);
if (idx >= 0) {
playTrack(idx);
document.getElementById(`track-${idx}`).scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}
}
window.addEventListener('hashchange', checkHash);
window.addEventListener('load', checkHash);
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.code === 'Space') { e.preventDefault(); togglePlay(); }
if (e.code === 'ArrowRight') { audio.currentTime += 5; }
if (e.code === 'ArrowLeft') { audio.currentTime -= 5; }
if (e.key === 'n') { nextTrack(); }
if (e.key === 'p') { prevTrack(); }
});
</script>
</body>
</html>