發現一個好玩的小東西 tone.js,就快速的 vibe coding 出一個可以把 Java 程式碼轉成 Lofi 音樂的工具,沒太多深究,純粹好玩。
可以自己把程式碼貼上去來產生,純前端,所以不用怕有什麼資安的問題
[Java to Lofi](https://mister33221.github.io/java-to-lofi.html)

```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("&","&").replaceAll("<","<").replaceAll(">",">");
}
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>
```