發現一個好玩的小東西 tone.js,就快速的 vibe coding 出一個可以把 Java 程式碼轉成 Lofi 音樂的工具,沒太多深究,純粹好玩。 可以自己把程式碼貼上去來產生,純前端,所以不用怕有什麼資安的問題 [Java to Lofi](https://mister33221.github.io/java-to-lofi.html) ![image](https://hackmd.io/_uploads/r1a9ov9Qbe.png) ```html <!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width,initial-scale=1" /> <title>Java → Lo-fi Band(Tone.js + Tree-sitter)</title> <style> :root{ --bg:#0b0f14; --panel:#111823; --panel2:#0f1620; --text:#e7eef7; --muted:rgba(231,238,247,.75); --border:rgba(231,238,247,.14); --accent:#66aaff; --good:#4ade80; --warn:#fbbf24; --bad:#fb7185; } *{ box-sizing:border-box; } body{ margin:0; font-family:system-ui,-apple-system,"Segoe UI",Arial,sans-serif; background:var(--bg); color:var(--text); } h1{ margin:0 0 10px 0; font-size:18px; font-weight:700; letter-spacing:.2px; } .wrap{ padding:14px; max-width:1320px; margin:0 auto; } .top{ background:linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); border:1px solid var(--border); border-radius:14px; padding:12px; } .row{ display:flex; gap:10px; align-items:center; flex-wrap:wrap; } button{ background:#1b2a3d; color:var(--text); border:1px solid var(--border); padding:8px 12px; border-radius:10px; cursor:pointer; } button:hover{ border-color:rgba(231,238,247,.28); } button:active{ transform:translateY(1px); } label{ display:flex; gap:8px; align-items:center; color:var(--muted); font-size:13px; } input[type="number"], select{ background:rgba(255,255,255,.03); border:1px solid var(--border); color:var(--text); padding:6px 8px; border-radius:10px; outline:none; } input[type="range"]{ width:150px; } .pill{ padding:6px 10px; border-radius:999px; border:1px solid var(--border); background:rgba(255,255,255,.03); color:var(--muted); font-size:12px; } .pill b{ color:var(--text); font-weight:700; } .mixer{ display:flex; gap:10px; flex-wrap:wrap; margin-top:10px; } .strip{ background:rgba(255,255,255,.03); border:1px solid var(--border); border-radius:12px; padding:10px; min-width:190px; } .strip h4{ margin:0 0 8px 0; font-size:12px; color:var(--muted); letter-spacing:.3px; } .strip .val{ font-variant-numeric:tabular-nums; color:var(--text); } .viz-wrap{ margin-top:10px; background:rgba(0,0,0,.2); border:1px solid var(--border); border-radius:14px; padding:10px; } canvas{ width:100%; height:180px; display:block; border-radius:10px; background:rgba(0,0,0,.55); } .bottom{ display:grid; grid-template-columns:1fr 1fr; gap:12px; margin-top:12px; } .panel{ background:var(--panel); border:1px solid var(--border); border-radius:14px; overflow:hidden; min-height:520px; display:flex; flex-direction:column; } .panel .hd{ padding:10px 12px; background:rgba(255,255,255,.03); border-bottom:1px solid var(--border); display:flex; align-items:center; justify-content:space-between; gap:10px; } .panel .hd .title{ font-size:13px; color:var(--muted); } .panel .bd{ padding:0; flex:1; display:flex; min-height:0; } textarea{ width:100%; height:100%; resize:none; border:0; outline:none; padding:12px; background:var(--panel2); color:var(--text); font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace; font-size:13px; line-height:1.55; tab-size:2; } .codeview{ width:100%; height:100%; overflow:auto; padding:10px 0; background:var(--panel2); font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono",monospace; font-size:13px; line-height:1.55; } .line{ display:grid; grid-template-columns:56px 1fr; gap:10px; padding:2px 12px; border-left:3px solid transparent; white-space:pre; } .ln{ color:rgba(231,238,247,.45); text-align:right; padding-right:6px; user-select:none; font-variant-numeric:tabular-nums; } .txt{ color:rgba(231,238,247,.92); } .line.active{ background:rgba(102,170,255,.16); border-left-color:var(--accent); } .line.active .ln{ color:rgba(231,238,247,.85); } .line.dim .txt{ color:rgba(231,238,247,.68); } @media (max-width:980px){ .bottom{ grid-template-columns:1fr; } .panel{ min-height:420px; } } </style> </head> <body> <div class="wrap"> <div class="top"> <h1>Java → Lo-fi</h1> <div class="row"> <button id="btnPlay">Parse & Play</button> <button id="btnStop">Stop</button> <label>Lo-fi 氛圍 <select id="mood"> <option value="cozy" selected>Cozy</option> <option value="dusty">Dusty</option> <option value="dreamy">Dreamy</option> </select> </label> <label>BPM <input id="bpm" type="number" value="86" min="50" max="140" /></label> <label>Swing <span id="swingTxt" style="color:var(--text);font-variant-numeric:tabular-nums;">0.30</span> <input id="swing" type="range" min="0" max="0.7" step="0.01" value="0.30" /> </label> <label>視覺化 <select id="vizMode"> <option value="waveform" selected>Waveform</option> <option value="fft">FFT</option> </select> </label> <label>解析度 <select id="vizSize"> <option value="256" selected>256</option> <option value="512">512</option> </select> </label> <span class="pill">狀態:<b id="status">idle</b></span> <span class="pill">Bars:<b id="bars">-</b></span> <span class="pill">Events:<b id="evCount">-</b></span> <span class="pill">Follow:<b id="follow">off</b></span> </div> <div class="mixer"> <div class="strip"> <h4>Master</h4> <div class="row" style="gap:8px;"> <label>Vol <span class="val" id="vMasterTxt">0.70</span></label> <input id="vMaster" type="range" min="0" max="1.2" step="0.01" value="0.70" /> </div> </div> <div class="strip"> <h4>Drums</h4> <div class="row" style="gap:8px;"> <label>Vol <span class="val" id="vDrumsTxt">0.95</span></label> <input id="vDrums" type="range" min="0" max="1.2" step="0.01" value="0.95" /> </div> </div> <div class="strip"> <h4>Bass</h4> <div class="row" style="gap:8px;"> <label>Vol <span class="val" id="vBassTxt">0.78</span></label> <input id="vBass" type="range" min="0" max="1.2" step="0.01" value="0.78" /> </div> </div> <div class="strip"> <h4>Chords</h4> <div class="row" style="gap:8px;"> <label>Vol <span class="val" id="vChordsTxt">0.62</span></label> <input id="vChords" type="range" min="0" max="1.2" step="0.01" value="0.62" /> </div> </div> <div class="strip"> <h4>Melody</h4> <div class="row" style="gap:8px;"> <label>Vol <span class="val" id="vMelodyTxt">0.55</span></label> <input id="vMelody" type="range" min="0" max="1.2" step="0.01" value="0.55" /> </div> </div> </div> <div class="viz-wrap"> <canvas id="viz"></canvas> </div> </div> <div class="bottom"> <div class="panel"> <div class="hd"> <div class="title">下方左:貼入程式碼(Java)</div> <span class="pill">tree-sitter-java</span> </div> <div class="bd"> <textarea id="code">// 貼上你的 Java public class Demo { public int add(int a, int b) { if (a > 0) { for (int i = 0; i < 3; i++) { b += i; } } else { while (b < 10) { b++; } } return a + b; } public void hello() { System.out.println("hi"); } }</textarea> </div> </div> <div class="panel"> <div class="hd"> <div class="title">下方右:播放時顯示程式碼 + 目前行號高亮(循環也會跟著動)</div> <span class="pill">Line follow</span> </div> <div class="bd"> <div id="codeView" class="codeview"></div> </div> </div> </div> </div> <!-- Tone.js:UMD 只載一次 --> <script src="https://cdn.jsdelivr.net/npm/tone@15.1.22/build/Tone.js"></script> <script type="module"> // -------------------- helpers -------------------- const Tone = window.Tone; const $ = (id) => document.getElementById(id); const clamp = (n,a,b)=>Math.max(a,Math.min(b,n)); function setStatus(text, level){ const el = $("status"); el.textContent = text; el.style.color = level === "good" ? "var(--good)" : level === "warn" ? "var(--warn)" : level === "bad" ? "var(--bad)" : "var(--text)"; } // -------------------- state (avoid touching Transport before unlock) -------------------- let audioUnlocked = false; const mix = { master: 0.70, drums: 0.95, bass: 0.78, chords: 0.62, melody: 0.55 }; const audio = { nodes: null, parts: [], analyser: null, vizRaf: null }; // -------------------- UI binding -------------------- function bindRange(id, key){ const el = $(id); const txt = $(id + "Txt"); const apply = () => { const v = Number(el.value); mix[key] = v; if(txt) txt.textContent = v.toFixed(2); if(audio.nodes?.[key]?.gain) audio.nodes[key].gain.value = v; }; el.addEventListener("input", apply); apply(); } bindRange("vMaster","master"); bindRange("vDrums","drums"); bindRange("vBass","bass"); bindRange("vChords","chords"); bindRange("vMelody","melody"); $("swing").addEventListener("input", () => { $("swingTxt").textContent = Number($("swing").value).toFixed(2); if(audioUnlocked){ try{ Tone.Transport.swing = Number($("swing").value); Tone.Transport.swingSubdivision = "8n"; }catch(e){} } }); // -------------------- code view -------------------- const codeView = $("codeView"); let activeLine = -1; function escapeHtml(s){ return s.replaceAll("&","&amp;").replaceAll("<","&lt;").replaceAll(">","&gt;"); } function renderCodeView(code){ const lines = code.replaceAll("\r\n","\n").replaceAll("\r","\n").split("\n"); let html = ""; for(let i=0;i<lines.length;i++){ const ln = i+1; html += '<div class="line dim" data-line="'+ln+'">' + '<div class="ln">'+ln+'</div>' + '<div class="txt">'+escapeHtml(lines[i])+'</div>' + '</div>'; } codeView.innerHTML = html; activeLine = -1; $("follow").textContent = "ready"; } function highlightLine(line){ if(!line || line < 1) return; if(activeLine === line) return; const prev = codeView.querySelector('.line[data-line="'+activeLine+'"]'); if(prev) prev.classList.remove("active"); const cur = codeView.querySelector('.line[data-line="'+line+'"]'); if(cur){ cur.classList.add("active"); cur.classList.remove("dim"); activeLine = line; const top = cur.offsetTop; const mid = codeView.clientHeight / 2; codeView.scrollTo({ top: Math.max(0, top - mid), behavior:"auto" }); } } renderCodeView($("code").value); $("code").addEventListener("input", () => { renderCodeView($("code").value); $("follow").textContent = "off"; }); // -------------------- tree-sitter init -------------------- const { Parser, Language } = await import("https://cdn.jsdelivr.net/npm/web-tree-sitter@0.25.3/tree-sitter.js"); await Parser.init({ locateFile(path){ return "https://cdn.jsdelivr.net/npm/web-tree-sitter@0.25.3/" + path; } }); const JAVA_WASM = "https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-java.wasm"; const JavaLang = await Language.load(JAVA_WASM); const parser = new Parser(); parser.setLanguage(JavaLang); // -------------------- music primitives -------------------- const NOTE_BASE = { C:0, D:2, E:4, F:5, G:7, A:9, B:11 }; function noteToMidi(note){ const m = /^([A-G])([#b]?)(-?\d+)$/.exec(note.trim()); if(!m) return 60; let sem = NOTE_BASE[m[1]]; if(m[2] === "#") sem += 1; if(m[2] === "b") sem -= 1; const oct = Number(m[3]); return (oct + 1) * 12 + sem; } function midiToNote(m){ const names = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]; const n = ((m % 12) + 12) % 12; const oct = Math.floor(m / 12) - 1; return names[n] + String(oct); } function timeOfStep(step){ const bars = Math.floor(step / 16); const remain = step % 16; const beats = Math.floor(remain / 4); const sixteenth = remain % 4; return `${bars}:${beats}:${sixteenth}`; } function hashToInt(str){ let h = 2166136261; for(let i=0;i<str.length;i++){ h ^= str.charCodeAt(i); h = Math.imul(h, 16777619); } return (h >>> 0); } // Lo-fi friendly scale: D minor pentatonic (relative-ish) const PENTA = [0, 3, 5, 7, 10]; function penta(baseMidi, seed){ const deg = PENTA[seed % PENTA.length]; return baseMidi + deg; } // -------------------- AST -> base events -------------------- function astToBaseEvents(tree, code){ const src = code.replaceAll("\r\n","\n").replaceAll("\r","\n"); const songSeed = hashToInt(src.slice(0, Math.min(6000, src.length))); const events = []; let step = 0; const nodeText = (node) => src.slice(node.startIndex, node.endIndex); const lineOf = (node) => (node.startPosition ? node.startPosition.row : 0) + 1; // 一個事件佔用的 step(16n) const DUR = { method: 8, // 半小節 if: 2, loop: 4, return: 4, assign: 1, call: 1, decl: 2, id: 1 }; function push(kind, seed, node, dur){ events.push({ kind, step, time: timeOfStep(step), line: lineOf(node), seed: (seed >>> 0), durSteps: dur }); step += dur; } function walk(node, ctx){ const depth = ctx.depth || 0; const methodSeed = ctx.methodSeed || 1; if(node.type === "method_declaration"){ const nameNode = node.childForFieldName("name"); const name = nameNode ? nodeText(nameNode) : "method"; const seed = (hashToInt(name) ^ songSeed) >>> 0; push("method", seed, node, DUR.method); const next = { depth: depth+1, methodSeed: seed }; for(let i=0;i<node.childCount;i++) walk(node.child(i), next); return; } if(node.type === "if_statement"){ push("if", (methodSeed + depth*13 + 17) ^ songSeed, node, DUR.if); } else if(node.type === "for_statement" || node.type === "enhanced_for_statement" || node.type === "while_statement"){ push("loop", (methodSeed + depth*17 + 29) ^ songSeed, node, DUR.loop); } else if(node.type === "return_statement"){ push("return", (methodSeed + 999 + depth*7) ^ songSeed, node, DUR.return); } else if(node.type === "local_variable_declaration"){ push("decl", (methodSeed + depth*5) ^ songSeed, node, DUR.decl); } else if(node.type === "assignment_expression"){ push("assign", (methodSeed + depth*3) ^ songSeed, node, DUR.assign); } else if(node.type === "method_invocation"){ const txt = nodeText(node); push("call", (hashToInt(txt) + methodSeed) ^ songSeed, node, DUR.call); } else if(node.type === "identifier"){ const txt = nodeText(node); const seed = ((hashToInt(txt) + methodSeed) ^ songSeed) >>> 0; // 降低密度,避免太吵 if((seed % 6) === 0){ push("id", seed, node, DUR.id); } } for(let i=0;i<node.childCount;i++){ walk(node.child(i), { depth: depth+1, methodSeed }); } } walk(tree.rootNode, { depth:0, methodSeed:1 }); const bars = Math.max(1, Math.ceil(step / 16)); const totalSteps = bars * 16; return { events, bars, totalSteps, songSeed }; } // -------------------- Arrange (Lo-fi 4 tracks) -------------------- function arrangeLofi(baseEvents, bars, totalSteps, songSeed){ // 每個 bar 一個 seed / 能量 const barSeeds = Array.from({length:bars}, ()=>songSeed); const barEnergy = Array.from({length:bars}, ()=>0); for(const e of baseEvents){ const b = Math.floor(e.step / 16); if(b<0 || b>=bars) continue; barSeeds[b] = (barSeeds[b] ^ e.seed) >>> 0; let w = 0.4; if(e.kind==="loop") w = 2.5; if(e.kind==="if") w = 1.2; if(e.kind==="return") w = 2.0; barEnergy[b] += w; } // step -> nearest event(拿來做 line follow + 小變化) const sorted = baseEvents.slice().sort((a,b)=>a.step-b.step); const nearest = new Array(totalSteps); let idx = 0, last = null; for(let s=0;s<totalSteps;s++){ while(idx < sorted.length && sorted[idx].step <= s){ last = sorted[idx]; idx++; } nearest[s] = last; } // Lo-fi key center:D minor-ish(你也可以改) const rootMidi = noteToMidi("D2"); function barRoot(step){ const b = Math.floor(step / 16); const seed = barSeeds[b] >>> 0; const deg = PENTA[seed % PENTA.length]; // 少量換和聲:偶爾上移 2 半音 const bump = ((seed >> 4) % 5 === 0) ? 2 : 0; return rootMidi + deg + bump; } // --- Chords (1~2 per bar, mellow) --- const chords = []; function chordVoicing(root, seed){ // Lo-fi 常用:m7 / add9 느낌(但壓低音數,避免爆) // 這裡用 3 音:root, m3, m7 或 add9 const use9 = (seed % 3 === 0); const third = root + 3; const seventh = root + 10; const ninth = root + 14; const notes = use9 ? [root+12, third+12, ninth+12] : [root+12, third+12, seventh+12]; return notes.map(midiToNote); } for(let b=0;b<bars;b++){ const base = b*16; const seed = barSeeds[b]; const r = barRoot(base); const v = chordVoicing(r, seed); // 主和弦:bar 起點 chords.push({ time: timeOfStep(base), step:base, line:1, notes:v, dur:"1m", vel:0.35 }); // 能量高時:半小節換一下(更有流動) if(barEnergy[b] > 3.0){ const r2 = barRoot(base+8) + ((seed % 2) ? 5 : 0); // 偶爾移到四度 const v2 = chordVoicing(r2, seed+7); chords.push({ time: timeOfStep(base+8), step:base+8, line:1, notes:v2, dur:"2n", vel:0.32 }); } } // --- Bass (simple groove) --- const bass = []; for(let b=0;b<bars;b++){ const base = b*16; const r = barRoot(base); const fifth = r + 7; // 基本:1、3 拍 bass.push({ time: timeOfStep(base+0), step:base+0, line:1, note:midiToNote(r), dur:"4n", vel:0.55 }); bass.push({ time: timeOfStep(base+8), step:base+8, line:1, note:midiToNote(r), dur:"4n", vel:0.52 }); // 小變化:bar 末尾走向 const seed = barSeeds[b]; if((seed % 2) === 0){ bass.push({ time: timeOfStep(base+12), step:base+12, line:1, note:midiToNote(fifth), dur:"8n", vel:0.46 }); bass.push({ time: timeOfStep(base+14), step:base+14, line:1, note:midiToNote(r+2), dur:"8n", vel:0.42 }); } // 讓語法影響 bass:遇到 return 就加強根音 for(const e of baseEvents){ if(Math.floor(e.step/16)!==b) continue; if(e.kind==="return"){ bass.push({ time: timeOfStep(e.step), step:e.step, line:e.line, note:midiToNote(r-12), dur:"2n", vel:0.62 }); } if(e.kind==="loop" && (e.seed % 2 === 0)){ bass.push({ time: timeOfStep(e.step+2), step:e.step+2, line:e.line, note:midiToNote(r), dur:"8n", vel:0.50 }); } } } // --- Drums (lo-fi backbeat + hats, density from loops) --- const drums = []; for(let b=0;b<bars;b++){ const base = b*16; const energy = barEnergy[b]; const dense = energy > 3.2; // hats:8th(dense 時 16th 少量點綴) for(let s=0;s<16;s+=2){ const ev = nearest[base+s]; const vel = 0.20 + (dense ? 0.05 : 0); drums.push({ time: timeOfStep(base+s), step:base+s, line: ev?ev.line:1, type:"hat", vel }); } if(dense){ // 16th ghost hats(不每個都加,避免太吵) for(let s=1;s<16;s+=4){ const ev = nearest[base+s]; drums.push({ time: timeOfStep(base+s), step:base+s, line: ev?ev.line:1, type:"hat", vel:0.13 }); } } // snare:2、4 drums.push({ time: timeOfStep(base+4), step:base+4, line:1, type:"snare", vel:0.55 }); drums.push({ time: timeOfStep(base+12), step:base+12, line:1, type:"snare", vel:0.58 }); // kick:1、(有時)3+ & 變化 drums.push({ time: timeOfStep(base+0), step:base+0, line:1, type:"kick", vel:0.62 }); if((barSeeds[b] % 3) === 0) drums.push({ time: timeOfStep(base+8), step:base+8, line:1, type:"kick", vel:0.56 }); if(dense && (barSeeds[b] % 2) === 0) drums.push({ time: timeOfStep(base+10), step:base+10, line:1, type:"kick", vel:0.46 }); // 語法點綴:if / return -> snare ghost for(const e of baseEvents){ if(Math.floor(e.step/16)!==b) continue; if(e.kind==="if"){ drums.push({ time: timeOfStep(e.step), step:e.step, line:e.line, type:"snare", vel:0.22 }); } if(e.kind==="return"){ drums.push({ time: timeOfStep(e.step), step:e.step, line:e.line, type:"kick", vel:0.55 }); drums.push({ time: timeOfStep(e.step+1), step:e.step+1, line:e.line, type:"hat", vel:0.18 }); } } } // --- Melody (sparse, tied to identifiers/calls/return) --- const melody = []; const stepUsed = new Set(); for(const e of baseEvents){ if(!(e.kind==="call" || e.kind==="id" || e.kind==="return" || e.kind==="method")) continue; // 避免太密:同 step 只放一個 if(stepUsed.has(e.step)) continue; stepUsed.add(e.step); const base = barRoot(e.step) + 36; // 高一點 const n = penta(base, e.seed); const dur = (e.kind==="return" || e.kind==="method") ? "4n" : "8n"; const vel = (e.kind==="return") ? 0.48 : (e.kind==="method"?0.44:0.36); melody.push({ time:e.time, step:e.step, line:e.line, note:midiToNote(n), dur, vel }); // return 做一個小下行(很 lo-fi) if(e.kind==="return"){ const s2 = e.step + 2; if(s2 < totalSteps && !stepUsed.has(s2)){ stepUsed.add(s2); melody.push({ time: timeOfStep(s2), step:s2, line:e.line, note:midiToNote(n-2), dur:"8n", vel:0.34 }); } } } // --- Follow (loop safe) --- const follow = []; for(let s=0;s<totalSteps;s+=2){ const ev = nearest[s]; follow.push({ time: timeOfStep(s), step:s, line: ev ? ev.line : 1 }); } return { drums, bass, chords, melody, follow }; } // -------------------- Visualizer -------------------- const canvas = $("viz"); const ctx = canvas.getContext("2d"); function resizeCanvas(){ const rect = canvas.getBoundingClientRect(); const w = Math.max(1, Math.floor(rect.width)); const h = Math.max(1, Math.floor(rect.height || 180)); const dpr = window.devicePixelRatio || 1; canvas.width = Math.floor(w * dpr); canvas.height = Math.floor(h * dpr); ctx.setTransform(dpr,0,0,dpr,0,0); } window.addEventListener("resize", resizeCanvas); resizeCanvas(); function stopViz(){ if(audio.vizRaf) cancelAnimationFrame(audio.vizRaf); audio.vizRaf = null; if(audio.analyser?.dispose){ try{ audio.analyser.dispose(); }catch(e){} } audio.analyser = null; } function startViz(){ stopViz(); resizeCanvas(); const mode = $("vizMode").value; const size = Number($("vizSize").value || 256); audio.analyser = new Tone.Analyser(mode, size); const draw = () => { audio.vizRaf = requestAnimationFrame(draw); const rect = canvas.getBoundingClientRect(); const w = Math.max(1, Math.floor(rect.width)); const h = Math.max(1, Math.floor(rect.height || 180)); ctx.clearRect(0,0,w,h); ctx.fillStyle = "rgba(0,0,0,0.55)"; ctx.fillRect(0,0,w,h); ctx.strokeStyle = "rgba(231,238,247,0.25)"; ctx.strokeRect(0.5,0.5,w-1,h-1); if(!audio.analyser) return; const data = audio.analyser.getValue(); let maxAbs = 0; for(let i=0;i<data.length;i++){ const av = Math.abs(data[i]); if(av > maxAbs) maxAbs = av; } ctx.fillStyle = "rgba(231,238,247,0.85)"; ctx.font = "12px system-ui"; ctx.fillText(`mode=${mode} size=${data.length} maxAbs=${maxAbs.toFixed(4)}`, 10, 18); if(mode === "waveform"){ ctx.beginPath(); ctx.strokeStyle = "rgba(231,238,247,0.95)"; ctx.lineWidth = 2; const mid = h/2; for(let i=0;i<data.length;i++){ const x = (i/(data.length-1)) * w; const y = mid + data[i] * (h*0.35); if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y); } ctx.stroke(); ctx.beginPath(); ctx.strokeStyle = "rgba(231,238,247,0.22)"; ctx.moveTo(0, mid); ctx.lineTo(w, mid); ctx.stroke(); }else{ const barW = w / data.length; for(let i=0;i<data.length;i++){ const v = data[i]; // 可能是 dB-ish,保守映射 let norm = 0; if(Number.isFinite(v)){ if(v <= 0 && v >= -120) norm = (v + 120) / 120; else norm = Math.min(1, Math.abs(v)); } norm = clamp(norm, 0, 1); const barH = norm * (h * 0.82); ctx.fillStyle = "rgba(231,238,247,0.85)"; ctx.fillRect(i*barW, h-barH, Math.max(1, barW-0.5), barH); } } }; draw(); return audio.analyser; } // -------------------- dispose / stop -------------------- function disposeAudio(){ stopViz(); for(const p of audio.parts){ try{ p.dispose(); }catch(e){} } audio.parts = []; if(audio.nodes){ for(const k of Object.keys(audio.nodes)){ const n = audio.nodes[k]; if(n?.dispose){ try{ n.dispose(); }catch(e){} } } } audio.nodes = null; if(audioUnlocked){ try{ Tone.Transport.stop(); Tone.Transport.cancel(0); Tone.Transport.loop = false; }catch(e){} } $("follow").textContent = "off"; setStatus("idle"); } $("btnStop").addEventListener("click", () => { disposeAudio(); }); // -------------------- Lo-fi mood preset -------------------- const MOOD = { cozy: { bpm:86, lpHz: 6500, sat:0.05, reverbWet:0.08, delayWet:0.10, vinyl:0.020 }, dusty: { bpm:82, lpHz: 5600, sat:0.08, reverbWet:0.06, delayWet:0.08, vinyl:0.030 }, dreamy: { bpm:92, lpHz: 7200, sat:0.04, reverbWet:0.12, delayWet:0.16, vinyl:0.016 } }; function applyMoodToUI(){ const m = MOOD[$("mood").value]; $("bpm").value = m.bpm; } $("mood").addEventListener("change", applyMoodToUI); applyMoodToUI(); // -------------------- Build audio graph (after Tone.start) -------------------- async function buildLofiGraph(moodKey){ const m = MOOD[moodKey]; const nodes = {}; // master chain nodes.master = new Tone.Gain(mix.master); nodes.comp = new Tone.Compressor(-20, 3); nodes.lp = new Tone.Filter({ type:"lowpass", frequency:m.lpHz, rolloff:-24, Q:0.7 }); nodes.sat = new Tone.Distortion({ distortion:m.sat, oversample:"2x" }); nodes.rev = new Tone.Reverb({ decay:1.6, preDelay:0.01, wet:m.reverbWet }); try{ await nodes.rev.generate(); }catch(e){} nodes.tap = new Tone.Gain(1); nodes.lim = new Tone.Limiter(-1); nodes.master.chain(nodes.comp, nodes.sat, nodes.lp, nodes.rev, nodes.tap, nodes.lim, Tone.Destination); // visualizer startViz(); nodes.tap.connect(audio.analyser); // buses nodes.drums = new Tone.Gain(mix.drums).connect(nodes.master); nodes.bass = new Tone.Gain(mix.bass).connect(nodes.master); nodes.chords = new Tone.Gain(mix.chords).connect(nodes.master); nodes.melody = new Tone.Gain(mix.melody).connect(nodes.master); // drums nodes.kick = new Tone.MembraneSynth({ pitchDecay: 0.02, octaves: 8, envelope: { attack: 0.001, decay: 0.22, sustain: 0.0, release: 0.05 } }).connect(nodes.drums); nodes.snare = new Tone.NoiseSynth({ noise: { type: "white" }, envelope: { attack: 0.001, decay: 0.12, sustain: 0.0, release: 0.04 } }); nodes.snareBP = new Tone.Filter({ type:"bandpass", frequency:1800, Q:1.2 }); nodes.snare.chain(nodes.snareBP, nodes.drums); nodes.hat = new Tone.NoiseSynth({ noise: { type: "pink" }, envelope: { attack: 0.001, decay: 0.03, sustain: 0.0, release: 0.02 } }); nodes.hatHP = new Tone.Filter({ type:"highpass", frequency:6500, Q:0.7 }); nodes.hat.chain(nodes.hatHP, nodes.drums); // vinyl noise (subtle) nodes.vinyl = new Tone.Noise("pink"); nodes.vinylGain = new Tone.Gain(m.vinyl); nodes.vinylLP = new Tone.Filter({ type:"lowpass", frequency:2200, rolloff:-24, Q:0.7 }); nodes.vinyl.chain(nodes.vinylGain, nodes.vinylLP, nodes.master); nodes.vinyl.start(); // bass nodes.bassSynth = new Tone.MonoSynth({ oscillator: { type:"sine" }, envelope: { attack: 0.01, decay: 0.10, sustain: 0.55, release: 0.12 }, filter: { type:"lowpass", rolloff:-24, Q:0.8 }, filterEnvelope: { attack: 0.01, decay: 0.06, sustain: 0.2, release: 0.08, baseFrequency: 80, octaves: 2.0 } }).connect(nodes.bass); // chords (soft keys-ish) nodes.chordDelay = new Tone.FeedbackDelay({ delayTime:"8n", feedback:0.18, wet:m.delayWet }); nodes.chordLP = new Tone.Filter({ type:"lowpass", frequency:3200, rolloff:-24, Q:0.7 }); nodes.chordSynth = new Tone.PolySynth(Tone.Synth, { oscillator: { type:"triangle" }, envelope: { attack: 0.02, decay: 0.20, sustain: 0.45, release: 0.80 } }); nodes.chordSynth.maxPolyphony = 6; nodes.chordSynth.chain(nodes.chordDelay, nodes.chordLP, nodes.chords); // melody (soft pluck) nodes.melDelay = new Tone.FeedbackDelay({ delayTime:"8n", feedback:0.22, wet:m.delayWet * 0.9 }); nodes.melLP = new Tone.Filter({ type:"lowpass", frequency:4200, rolloff:-24, Q:0.8 }); nodes.melSynth = new Tone.MonoSynth({ oscillator: { type:"triangle" }, envelope: { attack: 0.005, decay: 0.18, sustain: 0.0, release: 0.12 }, filter: { type:"lowpass", rolloff:-24, Q:1.0 }, filterEnvelope: { attack: 0.005, decay: 0.10, sustain: 0.0, release: 0.10, baseFrequency: 400, octaves: 2.2 } }); nodes.melSynth.chain(nodes.melDelay, nodes.melLP, nodes.melody); audio.nodes = nodes; // 音量套用(避免拖拉後不一致) nodes.master.gain.value = mix.master; nodes.drums.gain.value = mix.drums; nodes.bass.gain.value = mix.bass; nodes.chords.gain.value = mix.chords; nodes.melody.gain.value = mix.melody; return nodes; } // -------------------- Play -------------------- $("btnPlay").addEventListener("click", async () => { try{ setStatus("starting...", "warn"); await Tone.start(); // ✅ 點擊解鎖 audioUnlocked = true; // reset disposeAudio(); // now it's safe to touch Transport const moodKey = $("mood").value; const m = MOOD[moodKey]; const bpm = Number($("bpm").value || m.bpm); Tone.Transport.bpm.value = bpm; const sw = Number($("swing").value); Tone.Transport.swing = sw; Tone.Transport.swingSubdivision = "8n"; $("swingTxt").textContent = sw.toFixed(2); const code = $("code").value; renderCodeView(code); setStatus("parsing...", "warn"); const tree = parser.parse(code); const r = astToBaseEvents(tree, code); $("bars").textContent = String(r.bars); $("evCount").textContent = String(r.events.length); const band = arrangeLofi(r.events, r.bars, r.totalSteps, r.songSeed); setStatus("building audio...", "warn"); await buildLofiGraph(moodKey); const loopEnd = `${r.bars}m`; Tone.Transport.loop = true; Tone.Transport.loopEnd = loopEnd; // Parts const pDrums = new Tone.Part((time, v) => { if(v.type === "kick") audio.nodes.kick.triggerAttackRelease("C1", "16n", time, v.vel); if(v.type === "snare") audio.nodes.snare.triggerAttackRelease("16n", time, v.vel); if(v.type === "hat") audio.nodes.hat.triggerAttackRelease("32n", time, v.vel); }, band.drums).start(0); const pBass = new Tone.Part((time, v) => { audio.nodes.bassSynth.triggerAttackRelease(v.note, v.dur, time, v.vel); }, band.bass).start(0); const pChords = new Tone.Part((time, v) => { audio.nodes.chordSynth.triggerAttackRelease(v.notes, v.dur, time, v.vel); }, band.chords).start(0); const pMelody = new Tone.Part((time, v) => { audio.nodes.melSynth.triggerAttackRelease(v.note, v.dur, time, v.vel); }, band.melody).start(0); // Follow:每次 loop 都會重跑(Part.loop=true) const pFollow = new Tone.Part((time, v) => { Tone.Draw.schedule(() => { $("follow").textContent = "on"; highlightLine(v.line); }, time); }, band.follow).start(0); const parts = [pDrums, pBass, pChords, pMelody, pFollow]; for(const p of parts){ p.loop = true; p.loopEnd = loopEnd; audio.parts.push(p); } setStatus(`playing (${moodKey})`, "good"); Tone.Transport.start("+0.05"); }catch(err){ console.error(err); setStatus("error (see console)", "bad"); } }); // 小提示:切回分頁後如果被瀏覽器暫停音訊 document.addEventListener("visibilitychange", () => { if(!audioUnlocked) return; try{ const ctx = Tone.getContext(); if(document.visibilityState === "visible" && ctx.state === "suspended"){ setStatus("suspended(再點 Play)", "warn"); } }catch(e){} }); </script> </body> </html> ```