389 lines
11 KiB
JavaScript
389 lines
11 KiB
JavaScript
// =============================================================================
|
|
// MarkovSpeed // p5.js Interactive Presidential Markov Engine
|
|
// Ported & expanded from Processing (2014-2016 / 2026) MarkovSpeech.pde
|
|
// Features: Dynamic Cadence, Semantic Force-Directed Graph, Web Speech TTS
|
|
// CC0 1.0 Universal / Public Domain. Author: --kof
|
|
// =============================================================================
|
|
|
|
const CORPUS_PRESIDENTIAL = [
|
|
"Vážení", "spoluobčané,", "stojíme", "na", "historické", "křižovatce.",
|
|
"Naše", "země", "potřebuje", "odvahu,", "rozvahu", "a", "společenský", "smír.",
|
|
"Svoboda", "a", "demokracie", "nejsou", "samozřejmostí,", "jsou", "každodenním", "závazkem.",
|
|
"V", "této", "chvíli", "musíme", "překonat", "všechny", "vnitřní", "spory.",
|
|
"Hospodářská", "síla", "a", "technologická", "tvořivost", "tvoří", "budoucnost.",
|
|
"Důvěra", "v", "instituce", "pramení", "z", "čistého", "svědomí", "a", "poctivé", "práce.",
|
|
"Pravda", "a", "láska", "musí", "zvítězit", "nad", "lží", "a", "nenávistí.",
|
|
"Kdo", "seje", "vítr,", "sklízí", "bouři,", "ale", "naše", "vůle", "zůstává", "pevná.",
|
|
"Přeji", "vám", "pevné", "zdraví,", "naději", "a", "víru", "v", "naši", "společnou", "vlast."
|
|
];
|
|
|
|
const CORPUS_CYBERPUNK = [
|
|
"Signál", "prochází", "měděným", "vedením", "v", "rytmu", "padesáti", "hertzů.",
|
|
"Autonomní", "uzly", "vyměňují", "pakety", "bez", "dohledu", "centrálních", "brokerů.",
|
|
"V", "paměti", "tmpfs", "rotuje", "bezeztrátový", "kruhový", "buffer.",
|
|
"Svoboda", "spočívá", "v", "vlastním", "železe,", "v", "čistém", "céčku", "a", "lokálních", "modelech.",
|
|
"Žádná", "korporace", "nemůže", "vypnout", "to,", "co", "běží", "na", "baterii", "ze", "slunce.",
|
|
"Tranzistory", "chladnou,", "ale", "lidská", "paměť", "zůstává", "trvalá.",
|
|
"Přenášíme", "pravdu", "přes", "šum", "éteru", "až", "na", "konec", "noci."
|
|
];
|
|
|
|
let nodes = [];
|
|
let particles = [];
|
|
let currentState = 0;
|
|
let isRunning = true;
|
|
let isBursting = false;
|
|
let currentCorpusIdx = 0;
|
|
|
|
let baseCadence = 12; // Words per second
|
|
let stepAccumulator = 0;
|
|
let lastStepTime = 0;
|
|
|
|
let speechHistory = [];
|
|
let currentSpeechStr = "";
|
|
let totalTokensEmitted = 0;
|
|
|
|
// TTS Web Speech
|
|
let ttsEnabled = false;
|
|
let synth = null;
|
|
let czechVoice = null;
|
|
|
|
// Visual layout
|
|
let centerX, centerY;
|
|
let radius = 260;
|
|
|
|
class MarkovNode {
|
|
constructor(id, token, x, y) {
|
|
this.id = id;
|
|
this.token = token;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.vx = 0;
|
|
this.vy = 0;
|
|
this.targets = [];
|
|
this.weights = [];
|
|
this.flashIntensity = 0.0;
|
|
}
|
|
|
|
addTransition(targetId, weight) {
|
|
this.targets.push(targetId);
|
|
this.weights.push(weight);
|
|
}
|
|
|
|
selectNext() {
|
|
if (this.targets.length === 0) return 0;
|
|
let total = this.weights.reduce((a, b) => a + b, 0);
|
|
let roll = random(total);
|
|
let accum = 0;
|
|
for (let i = 0; i < this.targets.length; i++) {
|
|
accum += this.weights[i];
|
|
if (roll <= accum) return this.targets[i];
|
|
}
|
|
return this.targets[0];
|
|
}
|
|
}
|
|
|
|
class Particle {
|
|
constructor(startX, startY, targetX, targetY, targetId) {
|
|
this.x = startX;
|
|
this.y = startY;
|
|
this.targetX = targetX;
|
|
this.targetY = targetY;
|
|
this.targetId = targetId;
|
|
this.progress = 0.0;
|
|
this.speed = random(0.08, 0.16);
|
|
}
|
|
|
|
update() {
|
|
this.progress += this.speed;
|
|
return this.progress >= 1.0;
|
|
}
|
|
|
|
render() {
|
|
let px = lerp(this.x, this.targetX, this.progress);
|
|
let py = lerp(this.y, this.targetY, this.progress);
|
|
noStroke();
|
|
fill(255, 204, 17, (1.0 - this.progress) * 255);
|
|
circle(px, py, 5);
|
|
}
|
|
}
|
|
|
|
function buildModel(vocab) {
|
|
nodes = [];
|
|
particles = [];
|
|
currentState = 0;
|
|
let total = vocab.length;
|
|
|
|
for (let i = 0; i < total; i++) {
|
|
// Spatial layout: Fibonacci phyllotaxis spiral
|
|
let angle = i * 2.39996323; // Golden angle
|
|
let r = sqrt(i / total) * radius;
|
|
let x = centerX + cos(angle) * r;
|
|
let y = centerY + sin(angle) * r;
|
|
nodes.push(new MarkovNode(i, vocab[i], x, y));
|
|
}
|
|
|
|
// Generate transition probabilities
|
|
for (let i = 0; i < total; i++) {
|
|
let n = nodes[i];
|
|
// Main sequential train
|
|
if (i + 1 < total) {
|
|
n.addTransition(i + 1, 0.65);
|
|
} else {
|
|
n.addTransition(0, 0.70); // loop
|
|
}
|
|
|
|
// Branching rhetorical attributions
|
|
let b1 = (i + 3) % total;
|
|
let b2 = (i + 7) % total;
|
|
let b3 = (i + 13) % total;
|
|
n.addTransition(b1, 0.18);
|
|
n.addTransition(b2, 0.11);
|
|
n.addTransition(b3, 0.06);
|
|
}
|
|
}
|
|
|
|
function setup() {
|
|
let container = document.getElementById("canvas-container");
|
|
let canvas = createCanvas(container.clientWidth, container.clientHeight);
|
|
canvas.parent("canvas-container");
|
|
|
|
centerX = width / 2;
|
|
centerY = height / 2;
|
|
radius = min(width, height) * 0.40;
|
|
|
|
buildModel(CORPUS_PRESIDENTIAL);
|
|
|
|
initTTS();
|
|
initUI();
|
|
}
|
|
|
|
function windowResized() {
|
|
let container = document.getElementById("canvas-container");
|
|
resizeCanvas(container.clientWidth, container.clientHeight);
|
|
centerX = width / 2;
|
|
centerY = height / 2;
|
|
radius = min(width, height) * 0.40;
|
|
buildModel(currentCorpusIdx === 0 ? CORPUS_PRESIDENTIAL : CORPUS_CYBERPUNK);
|
|
}
|
|
|
|
function initTTS() {
|
|
if ("speechSynthesis" in window) {
|
|
synth = window.speechSynthesis;
|
|
let loadVoices = () => {
|
|
let voices = synth.getVoices();
|
|
czechVoice = voices.find(v => v.lang.startsWith("cs")) || voices[0];
|
|
};
|
|
loadVoices();
|
|
if (synth.onvoiceschanged !== undefined) {
|
|
synth.onvoiceschanged = loadVoices;
|
|
}
|
|
}
|
|
}
|
|
|
|
function speakWord(word) {
|
|
if (!ttsEnabled || !synth) return;
|
|
// Clean punctuation
|
|
let clean = word.replace(/[.,!?;:]/g, "");
|
|
if (!clean) return;
|
|
let utter = new SpeechSynthesisUtterance(clean);
|
|
if (czechVoice) utter.voice = czechVoice;
|
|
utter.lang = "cs-CZ";
|
|
// Adjust rate to current speed
|
|
let rate = map(baseCadence, 1, 60, 0.85, 2.5);
|
|
utter.rate = constrain(rate, 0.5, 3.0);
|
|
utter.pitch = 1.0;
|
|
utter.volume = 0.9;
|
|
synth.speak(utter);
|
|
}
|
|
|
|
function initUI() {
|
|
const btnToggle = document.getElementById("btn-toggle");
|
|
btnToggle.addEventListener("click", () => {
|
|
isRunning = !isRunning;
|
|
btnToggle.classList.toggle("active", isRunning);
|
|
btnToggle.textContent = isRunning ? "PAUZA / START" : "ZASTAVENO";
|
|
});
|
|
|
|
const btnBurst = document.getElementById("btn-burst");
|
|
btnBurst.addEventListener("mousedown", () => { isBursting = true; });
|
|
btnBurst.addEventListener("mouseup", () => { isBursting = false; });
|
|
btnBurst.addEventListener("mouseleave", () => { isBursting = false; });
|
|
btnBurst.addEventListener("touchstart", (e) => { e.preventDefault(); isBursting = true; });
|
|
btnBurst.addEventListener("touchend", () => { isBursting = false; });
|
|
|
|
const btnTTS = document.getElementById("btn-tts");
|
|
btnTTS.addEventListener("click", () => {
|
|
ttsEnabled = !ttsEnabled;
|
|
btnTTS.classList.toggle("active", ttsEnabled);
|
|
btnTTS.textContent = ttsEnabled ? "TTS HLAS: ZAP" : "TTS HLAS: VYP";
|
|
if (ttsEnabled && synth) {
|
|
speakWord("Techné aktivováno");
|
|
}
|
|
});
|
|
|
|
const btnCorpus = document.getElementById("btn-corpus");
|
|
btnCorpus.addEventListener("click", () => {
|
|
currentCorpusIdx = 1 - currentCorpusIdx;
|
|
let isPres = (currentCorpusIdx === 0);
|
|
btnCorpus.textContent = isPres ? "KORPUS: PREZIDENTSKÝ" : "KORPUS: CYBERPUNK";
|
|
buildModel(isPres ? CORPUS_PRESIDENTIAL : CORPUS_CYBERPUNK);
|
|
currentSpeechStr = "> ";
|
|
updateTicker();
|
|
});
|
|
|
|
const speedSlider = document.getElementById("speed-slider");
|
|
const speedVal = document.getElementById("speed-val");
|
|
speedSlider.addEventListener("input", (e) => {
|
|
baseCadence = parseInt(e.target.value);
|
|
speedVal.textContent = baseCadence + " W/S";
|
|
});
|
|
}
|
|
|
|
function keyPressed() {
|
|
if (key === " ") {
|
|
isBursting = true;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function keyReleased() {
|
|
if (key === " ") {
|
|
isBursting = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function emitToken() {
|
|
if (nodes.length === 0) return;
|
|
|
|
let current = nodes[currentState];
|
|
current.flashIntensity = 1.0;
|
|
totalTokensEmitted++;
|
|
|
|
let token = current.token;
|
|
speakWord(token);
|
|
|
|
// Update speech text
|
|
currentSpeechStr += token + " ";
|
|
if (currentSpeechStr.length > 280) {
|
|
currentSpeechStr = "> ..." + currentSpeechStr.substring(currentSpeechStr.length - 220);
|
|
}
|
|
updateTicker(token);
|
|
|
|
// Pick next state
|
|
let nextState = current.selectNext();
|
|
let nextNode = nodes[nextState];
|
|
|
|
// Spawn visual packet particle
|
|
particles.push(new Particle(current.x, current.y, nextNode.x, nextNode.y, nextState));
|
|
currentState = nextState;
|
|
}
|
|
|
|
function updateTicker(lastToken = "") {
|
|
let elem = document.getElementById("speech-text");
|
|
if (elem) elem.textContent = currentSpeechStr;
|
|
|
|
let tag = document.getElementById("token-val");
|
|
if (tag && lastToken) tag.textContent = lastToken;
|
|
|
|
let tempo = document.getElementById("tempo-val");
|
|
let effectiveCadence = isBursting ? baseCadence * 3 : baseCadence;
|
|
if (tempo) tempo.textContent = (effectiveCadence * 60) + " WPM";
|
|
|
|
let entropy = document.getElementById("entropy-val");
|
|
if (entropy) {
|
|
let curr = nodes[currentState];
|
|
let H = curr ? (Math.log2(curr.targets.length) || 1.0).toFixed(2) : "2.41";
|
|
entropy.textContent = `H = ${H} bit`;
|
|
}
|
|
}
|
|
|
|
function draw() {
|
|
background(5, 8, 12, 45); // Phosphor persistence decay
|
|
|
|
// Center subtle cortical oscillation ring
|
|
let pulse = sin(frameCount * 0.05) * 6;
|
|
noFill();
|
|
stroke(26, 42, 58, 80);
|
|
circle(centerX, centerY, (radius * 2) + pulse);
|
|
|
|
// 1. Draw static and active transition lines
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
let n = nodes[i];
|
|
for (let tId of n.targets) {
|
|
let target = nodes[tId];
|
|
if (!target) continue;
|
|
|
|
let isCurr = (i === currentState);
|
|
if (isCurr) {
|
|
stroke(255, 204, 17, 180);
|
|
strokeWeight(1.5);
|
|
} else {
|
|
stroke(20, 36, 50, 40);
|
|
strokeWeight(0.7);
|
|
}
|
|
line(n.x, n.y, target.x, target.y);
|
|
}
|
|
}
|
|
|
|
// 2. Update and draw particles
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
let p = particles[i];
|
|
p.render();
|
|
if (p.update()) {
|
|
particles.splice(i, 1);
|
|
}
|
|
}
|
|
|
|
// 3. Draw nodes & labels
|
|
for (let i = 0; i < nodes.length; i++) {
|
|
let n = nodes[i];
|
|
n.flashIntensity *= 0.91; // Decay
|
|
|
|
let isCurrent = (i === currentState);
|
|
let glow = n.flashIntensity;
|
|
|
|
if (isCurrent || glow > 0.05) {
|
|
fill(255, 204, 17, 180 + glow * 75);
|
|
stroke(255, 230, 80, glow * 255);
|
|
strokeWeight(2);
|
|
circle(n.x, n.y, 10 + glow * 8);
|
|
|
|
// Glowing text label
|
|
noStroke();
|
|
fill(255, 240, 160);
|
|
textSize(12);
|
|
textAlign(CENTER, BOTTOM);
|
|
text(n.token, n.x, n.y - 10);
|
|
} else {
|
|
fill(35, 55, 75);
|
|
noStroke();
|
|
circle(n.x, n.y, 4);
|
|
|
|
fill(90, 120, 145, 160);
|
|
textSize(9);
|
|
textAlign(CENTER, TOP);
|
|
text(n.token, n.x, n.y + 6);
|
|
}
|
|
}
|
|
|
|
// 4. Cadence timing engine
|
|
if (isRunning) {
|
|
let effectiveCadence = isBursting ? baseCadence * 3 : baseCadence;
|
|
let intervalSec = 1.0 / effectiveCadence;
|
|
let now = millis() / 1000.0;
|
|
|
|
if (lastStepTime === 0) lastStepTime = now;
|
|
let dt = now - lastStepTime;
|
|
lastStepTime = now;
|
|
stepAccumulator += dt;
|
|
|
|
if (stepAccumulator >= intervalSec) {
|
|
emitToken();
|
|
stepAccumulator = 0;
|
|
}
|
|
} else {
|
|
lastStepTime = millis() / 1000.0;
|
|
}
|
|
}
|