Cortazar-Dice / docs /architecture_explorer.html
macayaven's picture
Upload folder using huggingface_hub
335196b verified
Raw
History Blame Contribute Delete
29 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortázar TTS Architecture Explorer</title>
<style>
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-hover: #334155;
--primary: #8b5cf6;
--secondary: #ec4899;
--text: #f8fafc;
--text-dim: #94a3b8;
--accent: #22d3ee;
--border: #334155;
}
body {
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
margin: 0;
display: flex;
height: 100vh;
overflow: hidden;
}
.canvas {
flex: 1;
position: relative;
background-image:
radial-gradient(circle at 1px 1px, var(--surface-hover) 1px, transparent 0);
background-size: 40px 40px;
display: flex;
justify-content: center;
align-items: center;
overflow: auto;
}
.sidebar {
width: 400px;
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(10px);
border-left: 1px solid var(--border);
padding: 2rem;
display: flex;
flex-direction: column;
gap: 1.5rem;
box-shadow: -4px 0 20px rgba(0,0,0,0.3);
z-index: 10;
}
h1 {
background: linear-gradient(135deg, var(--primary), var(--secondary));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin: 0;
font-size: 1.5rem;
font-weight: 800;
}
.node {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1rem 1.5rem;
min-width: 140px;
text-align: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.node:hover, .node.active {
border-color: var(--primary);
box-shadow: 0 0 0 2px var(--primary), 0 10px 15px -3px rgba(0, 0, 0, 0.3);
transform: translateY(-2px);
background: var(--surface-hover);
}
.node-icon {
font-size: 1.5rem;
margin-bottom: 0.5rem;
display: block;
}
.node-title {
font-weight: 600;
font-size: 0.9rem;
}
.node-subtitle {
font-size: 0.75rem;
color: var(--text-dim);
margin-top: 0.25rem;
}
.node-order {
position: absolute;
top: 0.35rem;
left: 0.45rem;
width: 24px;
height: 24px;
border-radius: 999px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(34, 211, 238, 0.15);
color: var(--accent);
font-size: 0.75rem;
font-weight: 700;
border: 1px solid rgba(34, 211, 238, 0.3);
pointer-events: none;
}
.flow-container {
display: flex;
gap: 4rem;
align-items: center;
max-width: 1200px;
padding: 4rem;
}
.column {
display: flex;
flex-direction: column;
gap: 2rem;
position: relative;
}
/* Connecting Lines */
.connector {
position: absolute;
background: var(--border);
z-index: -1;
transition: background 0.3s;
}
.arrow-right::after {
content: "→";
position: absolute;
right: -2rem;
top: 50%;
transform: translateY(-50%);
color: var(--border);
font-size: 1.5rem;
}
/* Detail Panel Styles */
.detail-header {
display: flex;
align-items: center;
gap: 1rem;
border-bottom: 1px solid var(--border);
padding-bottom: 1rem;
}
.detail-icon {
font-size: 2rem;
background: var(--surface);
padding: 0.75rem;
border-radius: 12px;
border: 1px solid var(--border);
}
.detail-title {
font-size: 1.25rem;
font-weight: 700;
}
.detail-badge {
font-size: 0.7rem;
padding: 0.2rem 0.5rem;
border-radius: 99px;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 700;
}
.badge-arm { background: rgba(34, 211, 238, 0.1); color: var(--accent); }
.badge-core { background: rgba(139, 92, 246, 0.1); color: var(--primary); }
.badge-fix { background: rgba(236, 72, 153, 0.1); color: var(--secondary); }
.badge-data { background: rgba(34, 211, 238, 0.1); color: var(--accent); }
.badge-train { background: rgba(139, 92, 246, 0.1); color: var(--primary); }
.badge-eval { background: rgba(236, 72, 153, 0.1); color: var(--secondary); }
.badge-docs { background: rgba(148, 163, 184, 0.15); color: var(--text-dim); }
.badge-idea { background: rgba(34, 211, 238, 0.1); color: var(--accent); }
.badge-io { background: rgba(148, 163, 184, 0.15); color: var(--text-dim); }
.detail-content {
line-height: 1.6;
color: var(--text-dim);
font-size: 0.95rem;
}
code {
background: rgba(0,0,0,0.3);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: 'JetBrains Mono', monospace;
font-size: 0.85em;
color: var(--accent);
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
pre {
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem;
color: var(--text);
font-size: 0.85rem;
white-space: pre-wrap;
}
.tech-stack {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1rem;
}
.tech-tag {
background: var(--surface);
border: 1px solid var(--border);
padding: 0.25rem 0.75rem;
border-radius: 6px;
font-size: 0.8rem;
color: var(--text);
}
/* Pipeline Flow SVG */
svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
path {
fill: none;
stroke: var(--border);
stroke-width: 2;
}
.node-row {
position: absolute;
bottom: 2rem;
left: 2rem;
display: flex;
gap: 1rem;
flex-wrap: wrap;
max-width: 980px;
}
.node.compact {
min-width: 110px;
}
</style>
</head>
<body>
<div class="canvas" id="canvas">
<svg id="connections"></svg>
<div class="flow-container">
<!-- INPUT -->
<div class="column">
<div class="node" data-id="input" onclick="selectNode('input')">
<span class="node-order">1</span>
<span class="node-icon">📄</span>
<div class="node-title">Input</div>
<div class="node-subtitle">Text & Ref Audio</div>
</div>
</div>
<div class="arrow-right"></div>
<!-- PREPROC -->
<div class="column">
<div class="node" data-id="preprocess" onclick="selectNode('preprocess')">
<span class="node-order">2</span>
<span class="node-icon">🔄</span>
<div class="node-title">Preprocessing</div>
<div class="node-subtitle">Soundfile & Whisper</div>
</div>
</div>
<div class="arrow-right"></div>
<!-- MODEL -->
<div class="column">
<div class="node" data-id="f5tts" onclick="selectNode('f5tts')">
<span class="node-order">3</span>
<span class="node-icon">🧠</span>
<div class="node-title">F5-TTS (DiT)</div>
<div class="node-subtitle">Diffusion Transformer</div>
</div>
</div>
<div class="arrow-right"></div>
<!-- VOCODER -->
<div class="column">
<div class="node" data-id="vocoder" onclick="selectNode('vocoder')">
<span class="node-order">4</span>
<span class="node-icon">🎼</span>
<div class="node-title">Vocos</div>
<div class="node-subtitle">Mel -> Waveform</div>
</div>
</div>
<div class="arrow-right"></div>
<!-- OUTPUT -->
<div class="column">
<div class="node" data-id="output" onclick="selectNode('output')">
<span class="node-order">5</span>
<span class="node-icon">🔊</span>
<div class="node-title">Output</div>
<div class="node-subtitle">wav file</div>
</div>
</div>
</div>
<div class="node-row">
<div class="node compact" data-id="data_prep" onclick="selectNode('data_prep')">
<span class="node-icon">🧪</span>
<div class="node-title">Data Prep</div>
</div>
<div class="node compact" data-id="training" onclick="selectNode('training')">
<span class="node-icon">🏋️</span>
<div class="node-title">Training</div>
</div>
<div class="node compact" data-id="evaluation" onclick="selectNode('evaluation')">
<span class="node-icon">📊</span>
<div class="node-title">Evaluation</div>
</div>
<div class="node compact" data-id="use_cases" onclick="selectNode('use_cases')">
<span class="node-icon"></span>
<div class="node-title">Use Cases</div>
</div>
<div class="node compact" data-id="glossary" onclick="selectNode('glossary')">
<span class="node-icon">📚</span>
<div class="node-title">Glossary</div>
</div>
<div class="node compact" data-id="params" onclick="selectNode('params')">
<span class="node-icon">🎛️</span>
<div class="node-title">Parameters</div>
</div>
<div class="node compact" data-id="model_internals" onclick="selectNode('model_internals')">
<span class="node-icon">🧬</span>
<div class="node-title">F5-TTS Diagram</div>
</div>
<div class="node compact" data-id="aux_models" onclick="selectNode('aux_models')">
<span class="node-icon">🧩</span>
<div class="node-title">Other Models</div>
</div>
<div class="node compact" data-id="requirements" onclick="selectNode('requirements')">
<span class="node-icon"></span>
<div class="node-title">Requirements</div>
</div>
<div class="node compact" data-id="data_layout" onclick="selectNode('data_layout')">
<span class="node-icon">🗂️</span>
<div class="node-title">Data Layout</div>
</div>
<div class="node compact" data-id="docker" onclick="selectNode('docker')">
<span class="node-icon">🐳</span>
<div class="node-title">Docker Env</div>
</div>
<div class="node compact" data-id="arm64" onclick="selectNode('arm64')">
<span class="node-icon">⚙️</span>
<div class="node-title">ARM64 Fixes</div>
</div>
</div>
</div>
<div class="sidebar">
<div>
<h1>Architecture Explorer</h1>
<p style="color: var(--text-dim); margin-top: 0.5rem; font-size: 0.9rem;">Interactive guide to the Cortázar TTS pipeline (data → train → eval → infer). The top row is the <b>inference path</b>; training produces the checkpoint that the model node consumes. For end-to-end workflow, see <code>docs/PIPELINE.md</code> and <code>docs/DATA_LAYOUT.md</code>.</p>
</div>
<div id="detail-panel">
<div class="detail-header">
<div class="detail-icon" id="d-icon">ℹ️</div>
<div>
<div class="detail-title" id="d-title">Welcome</div>
<div id="d-badges" style="display: flex; gap: 0.5rem; margin-top: 0.25rem;"></div>
</div>
</div>
<div class="detail-content" id="d-content">
Select a component from the diagram to see how it works, what libraries it uses, and specific ARM64 optimizations we implemented.
</div>
<div class="tech-stack" id="d-stack"></div>
</div>
</div>
<script>
const data = {
input: {
title: "Input Handling",
icon: "📄",
badges: ["core"],
content: "<b>Inference inputs</b>: target text + reference audio (style prompt) + optional reference text.<br><br><b>Scripts</b>: <code><a href=\"../scripts/cortazarize.sh\">scripts/cortazarize.sh</a></code> (text→TTS or audio→ASR→TTS) → <code><a href=\"../scripts/infer_on_demand.sh\">scripts/infer_on_demand.sh</a></code><code><a href=\"../scripts/speak.py\">scripts/speak.py</a></code>.<br><br><b>Output</b>: a single WAV in <code>outputs/infer/</code>.<br><br>Docs: <code><a href=\"./PIPELINE.md\">docs/PIPELINE.md</a></code> and <code><a href=\"./TUNING.md\">docs/TUNING.md</a></code>.",
stack: ["bash", "argparse", "pathlib"]
},
preprocess: {
title: "Preprocessing Module",
icon: "🔄",
badges: ["fix", "core"],
content: "Reference prompts are trimmed + normalized for stable conditioning:<br><br>1) <b>Ref audio trimming</b>: silence-based clipping to ~12s (keeps prompts short and avoids unstable long conditioning).<br>2) <b>Ref text</b>: if missing, we use Whisper ASR (Transformers pipeline). If the ref clip comes from the prepared dataset, we look up its <code>text</code> and skip ASR.<br>3) <b>Chunking + reassembly</b>: long target texts are chunked and reassembled with overlap/cross-fade (and optional overlap alignment) to reduce seams. You can control splits with <code>--max_chars</code> (fewer chunks) or <code>--no_chunk</code> (single-chunk attempt).<br><br><b>Critical fix:</b> prompt mel length is computed from the model’s mel extractor so we slice continuations correctly (prevents prompt-tail words leaking into the generated audio).<br><br><b>Tuning knobs:</b> <code>--speed</code>, <code>--cross_fade_sec</code>, <code>--nfe_step</code>, <code>--cfg_strength</code>.",
stack: ["soundfile", "pydub", "transformers (Whisper)", "numpy"]
},
data_prep: {
title: "Data Preparation",
icon: "🧪",
badges: ["data", "core", "io"],
content: "<b>Input</b>: raw recordings in <code>raw_audio/</code> (plus optional transcripts).<br><b>Process</b>: chunking + VAD + diarization + ASR + speaker pruning.<br><b>Script</b>: <code><a href=\"../scripts/core/prepare_data.py\">scripts/core/prepare_data.py</a></code> (full pipeline wrapper: <code><a href=\"../scripts/pipeline/run_spark.sh\">scripts/pipeline/run_spark.sh</a></code>).<br><b>Output</b>: segments in <code>dataset_prepared/wavs/</code> + HF dataset in <code>dataset_prepared/raw/</code> (fields: <code>audio_path</code>, <code>text</code>, <code>duration</code>) + <code>speaker_prune_report.jsonl</code>.",
stack: ["pyannote", "whisper", "datasets", "ffmpeg"]
},
training: {
title: "Training / Finetune",
icon: "🏋️",
badges: ["train", "core", "io"],
content: "<b>Input</b>: HF dataset synced to <code>F5-TTS/data/cortazar_v2_custom/</code> + base model at <code>F5-TTS/ckpts/base_models/</code>.<br><b>Entrypoint</b>: <code><a href=\"../scripts/core/train.py\">scripts/core/train.py</a></code> (wraps <code>f5_tts.train.finetune_cli</code>).<br><b>Monitoring</b>: <code><a href=\"../scripts/pipeline/train_watchdog.sh\">scripts/pipeline/train_watchdog.sh</a></code> + W&B.<br><b>Output</b>: checkpoints + sample WAVs in <code>F5-TTS/ckpts/cortazar_v2/</code> and logs in <code>outputs/logs/train.log</code>.<br><b>Required for inference</b>: the model node loads <code>model_last.pt</code> (or a specific <code>model_*.pt</code>).",
stack: ["accelerate", "pytorch", "wandb"]
},
evaluation: {
title: "Evaluation / Monitoring",
icon: "📊",
badges: ["eval", "core", "io"],
content: "<b>Online checks</b>: fixed-sample eval (<code><a href=\"../scripts/pipeline/eval_fixed_samples_daemon.sh\">scripts/pipeline/eval_fixed_samples_daemon.sh</a></code>), fail-fast gate (<code><a href=\"../scripts/pipeline/failfast_gate.sh\">scripts/pipeline/failfast_gate.sh</a></code>), periodic inference (<code><a href=\"../scripts/pipeline/periodic_infer.sh\">scripts/pipeline/periodic_infer.sh</a></code>).<br><b>Metrics</b>: WER (ASR), speaker similarity, trend checks; logs to W&B via <code><a href=\"../scripts/pipeline/eval_audio_metrics.py\">scripts/pipeline/eval_audio_metrics.py</a></code>.<br><b>Output</b>: JSONL metrics in <code>outputs/infer/*scores.jsonl</code> and W&B dashboards.",
stack: ["wandb", "whisper", "pyannote"]
},
f5tts: {
title: "F5-TTS Model",
icon: "🧠",
badges: ["core", "sota"],
content: "The heart of the system. F5-TTS is a <b>Flow Matching</b> model based on a <b>Diffusion Transformer (DiT)</b>.<br><br>It treats speech generation as iterative denoising over mel‑spectrograms. It conditions on text tokens + a reference‑audio mel prompt and generates a continuation spectrogram that is decoded by the vocoder.<br><br><b>Prerequisite</b>: requires a trained checkpoint from the Training step (e.g., <code>F5-TTS/ckpts/cortazar_v2/model_last.pt</code>).",
stack: ["pytorch", "einops", "accelerate", "hydra"]
},
vocoder: {
title: "Vocos Vocoder",
icon: "🎼",
badges: ["core", "fast"],
content: "Converts the Mel Spectrogram generated by F5-TTS into an actual audio waveform. <br><br><b>Vocos</b> is an adversarial Fourier vocoder that is significantly faster and higher quality than older vocoders like HiFi-GAN. We install this with `--no-deps` to avoid breaking the PyTorch environment.",
stack: ["vocos", "torch-fft"]
},
output: {
title: "Output Generation",
icon: "🔊",
badges: ["result"],
content: "The final waveform is saved to a `.wav` file in <code>outputs/infer/</code> (and optionally ref ensembles in <code>outputs/ref_ensembles/</code>).<br><br>The system automatically manages sample rates (usually 24kHz) and normalization before saving via <code>soundfile.write</code>.",
stack: ["soundfile"]
},
use_cases: {
title: "Other Use Cases",
icon: "✨",
badges: ["idea"],
content: "<b>Speech → Speech (style transfer)</b>: re‑synthesize an input recording in Cortázar style via <code><a href=\"../scripts/cortazarize.sh\">scripts/cortazarize.sh</a></code> with <code>--input_audio</code>. <i>Caveat</i>: ASR errors propagate; provide a clean transcript when possible.<br><br><b>Long‑form narration</b>: synthesize chapters using a stable reference ensemble (<code><a href=\"../scripts/pipeline/build_ref_ensemble.py\">scripts/pipeline/build_ref_ensemble.py</a></code>). <i>Caveat</i>: long texts are chunked; tune <code>--cross_fade_sec</code> and <code>--speed</code> to reduce seams.<br><br><b>QA / regression checks</b>: fixed samples and checkpoint sweeps (<code><a href=\"../scripts/tune_cortazarize.sh\">scripts/tune_cortazarize.sh</a></code>). <i>Caveat</i>: these are monitoring only, not training data.",
stack: ["asr", "ref-ensembles", "qa"]
},
glossary: {
title: "Glossary / Acronyms",
icon: "📚",
badges: ["docs"],
content: "<b>ASR</b>: automatic speech recognition (Whisper).<br><b>VAD</b>: voice activity detection (speech/non‑speech).<br><b>WER</b>: word error rate (ASR metric).<br><b>CFG</b>: classifier‑free guidance strength (sampling control).<br><b>NFE</b>: number of function evaluations (diffusion steps).<br><b>DiT</b>: diffusion transformer backbone.<br><b>CFM</b>: conditional flow matching model wrapper.<br><b>W&B</b>: Weights & Biases logging.",
stack: ["asr", "metrics", "diffusion"]
},
params: {
title: "Parameter Effects",
icon: "🎛️",
badges: ["docs", "core"],
content: "<b>speed</b>: &lt;1.0 slows cadence, &gt;1.0 speeds up (affects duration).<br><b>cross_fade_sec</b>: overlap between chunks; higher smooths seams but can blur words; 0 disables.<br><b>nfe_step</b>: diffusion steps; higher = better quality, slower.<br><b>cfg_strength</b>: guidance strength; higher = tighter text adherence, can sound strained.<br><b>sway_sampling_coef</b>: flow sampling tweak; negative (e.g., -1) often improves quality.<br><b>max_chars</b>: larger chunks = fewer splits; too large can destabilize long text.<br><b>no_chunk</b>: try single‑chunk generation (risk of failure for long text).<br><b>ref_mode</b>: <i>ensemble</i> blends multiple clips for stability, <i>manual</i> uses one clip.<br><b>ensemble_k</b>: number of ref clips; more can stabilize tone but increases variance if clips differ.<br><b>asr_model</b>: larger = better transcription, slower; recommend <code>openai/whisper-large-v3</code> on CUDA when possible.<br><b>device</b>: <i>cuda</i> faster, <i>cpu</i> for low‑contention runs.",
stack: ["tuning", "asr", "quality"]
},
model_internals: {
title: "F5-TTS Internal Diagram",
icon: "🧬",
badges: ["docs", "core"],
content: "<b>High‑level flow:</b><br><pre>Text → tokenizer → text embeddings\nRef audio → mel extractor → prompt mel\n └─────────────┬──────────────┘\n ↓\n DiT backbone (CFM / flow matching)\n ↓\n generated mel continuation\n ↓\n Vocos vocoder\n ↓\n 24kHz waveform</pre><b>Notes:</b> model predicts a continuation mel conditioned on the prompt mel + text. The prompt mel length is computed from the model’s mel extractor to avoid boundary drift.",
stack: ["DiT", "CFM", "mel", "vocos"]
},
aux_models: {
title: "Other Models in the Pipeline",
icon: "🧩",
badges: ["docs", "core"],
content: "<b>Whisper (ASR)</b>: transcribes audio when no text is provided (inference) and during data prep.<br><b>Pyannote diarization</b>: separates speakers; helps filter interviewer segments.<br><b>Speaker embedding model</b>: verifies target speaker similarity; used for pruning and QA.<br><br><b>Why they matter</b>: these models don’t affect gradients directly, but errors here create bad audio↔text pairs and hurt training quality.",
stack: ["whisper", "pyannote", "speaker-emb"]
},
requirements: {
title: "Requirements / Constraints",
icon: "✅",
badges: ["docs"],
content: "<b>Platform</b>: DGX Spark (ARM64).<br><b>Hard rule</b>: keep the NVIDIA base image stack intact (PyTorch/torchaudio/CUDA).<br><b>Containers</b>: <code>cortex_runner_spark</code> (training) and <code>cortex_infer_spark</code> (inference).<br><b>Storage</b>: large disk required for raw audio, prepared dataset, checkpoints, and W&B artifacts.",
stack: ["docker", "arm64", "cuda"]
},
data_layout: {
title: "Data Layout Map",
icon: "🗂️",
badges: ["docs", "io"],
content: "High‑level flow (paths):<br><pre>raw_audio/\n → dataset_sources/\n → dataset_prepared/wavs/\n → dataset_prepared/raw/\n → F5-TTS/data/cortazar_v2_custom/\n → F5-TTS/ckpts/cortazar_v2/\n → outputs/infer/</pre><b>Canonical docs</b>: <code><a href=\"./DATA_LAYOUT.md\">docs/DATA_LAYOUT.md</a></code>, <code><a href=\"./PIPELINE.md\">docs/PIPELINE.md</a></code>.",
stack: ["paths", "artifacts"]
},
docker: {
title: "Docker Environment",
icon: "🐳",
badges: ["infra", "nvidia"],
content: "Running on <code>nvcr.io/nvidia/pytorch:25.12-py3</code> (see <code>Dockerfile.spark</code>).<br><br><b>Hard constraint:</b> do not upgrade/replace the base image’s PyTorch/torchaudio/CUDA stack. Python deps are installed with <code>--no-deps</code> where needed to avoid pip overwriting the optimized build.",
stack: ["docker", "nvidia-container-toolkit"]
},
arm64: {
title: "ARM64 Adaptations",
icon: "⚙️",
badges: ["fix"],
content: "DGX Spark is ARM64 (Grace Blackwell). The main stability rule is: <b>keep the NVIDIA base image stack intact</b>.<br><br>Patterns used here:<br>1) <b>Manual dependency management</b>: avoid pip changing torch/torchaudio.<br>2) <b>Audio I/O fallbacks</b>: prefer <code>soundfile</code> paths when torchaudio backends are brittle.<br>3) <b>Compatibility shims</b>: small monkeypatches where third-party libs assume x86/standard semver.",
stack: ["arm64", "linux-aarch64"]
}
};
function selectNode(id) {
// UI Update
document.querySelectorAll('.node').forEach(n => n.classList.remove('active'));
document.querySelector(`.node[data-id="${id}"]`).classList.add('active');
// Data Update
const info = data[id];
document.getElementById('d-icon').textContent = info.icon;
document.getElementById('d-title').textContent = info.title;
document.getElementById('d-content').innerHTML = info.content;
// Badges
const badgeContainer = document.getElementById('d-badges');
badgeContainer.innerHTML = '';
info.badges.forEach(b => {
const badge = document.createElement('span');
badge.className = `detail-badge badge-${b}`;
badge.textContent = b;
badgeContainer.appendChild(badge);
});
// Stack
const stackContainer = document.getElementById('d-stack');
stackContainer.innerHTML = '';
info.stack.forEach(s => {
const tag = document.createElement('span');
tag.className = 'tech-tag';
tag.textContent = s;
stackContainer.appendChild(tag);
});
}
</script>
</body>
</html>