feat: initial commit of markovspeed p5.js presidential speech engine
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
// =============================================================================
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user