Compare commits

...

3 Commits

Author SHA1 Message Date
Carsten Graf
2d4831349b feat(tts): browser-based MP3 announcer for finished times
All checks were successful
/ build (push) Successful in 3m58s
Plays the just-finished time via Web Audio API on index.html and on
new top-1 entries on leaderboard.html. Snippets are pre-rendered as
German neural-TTS MP3s (numbers 0-99 spoken naturally as
"vierzehn", "sechsundneunzig" etc.) and decoded into AudioBuffers
once at page load, then chained gaplessly via start(when, offset,
duration) — leading/trailing silence in each MP3 is detected and
skipped so words flow without pauses. A floating speaker toggle
persists in localStorage and doubles as the user gesture that
unlocks the AudioContext on autoplay-restricted browsers (SmartTV,
iOS Safari).

Hundredths formatting mirrors the ESP's float-truncation via
Math.fround so the announced value always matches the displayed
string, even at hundredths boundaries where double/float rounding
diverges. Preload runs at concurrency 2 with a 2 s start delay so
the 107 MP3 fetches don't starve /api/data and freeze the live
timer.

Regenerator script: tools/generate-tts.py (requires edge-tts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:49 +02:00
Carsten Graf
5beced0041 Merge feat/lb-fly-animation: fly-down animation on lap reset
Some checks failed
/ build (push) Has been cancelled
2026-05-03 17:24:05 +02:00
Carsten Graf
fd18d0cd22 feat(leaderboard): fly-down animation on lap reset
All checks were successful
/ build (push) Successful in 4m6s
Beim Übergang finished -> ready (Auto-Reset) fliegt die große
Lauf-Zeit aus #time1/#time2 nach unten in die Leaderboard-Liste.
Die bestehenden Einträge werden dabei nach unten geschoben, um
Platz zu machen.

Auto-Trigger beim Leaderboard-Polling entfernt; Animation läuft
jetzt ausschließlich am Status-Übergang über einen Snapshot, der
vor kickDisplayScheduler() eingefroren wird.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 17:23:39 +02:00
111 changed files with 573 additions and 0 deletions

View File

@@ -27,6 +27,7 @@
<div id="live-clock" class="live-clock">--:--:--</div>
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
<a href="/settings" class="settings-btn">⚙️</a>
<script src="/tts.js" defer></script>
<div class="heartbeat-indicators">
<div
@@ -529,6 +530,143 @@
);
}
// -------- Fly-down Animation --------
// Wird ausgelöst beim Status-Übergang finished -> ready (kurz vor dem
// Auto-Reset des Backends). Damit bleibt die große Zeit oben sichtbar
// bis der Backend resettet, und fliegt dann erst nach unten.
// Snapshot der Quelle einfrieren, BEVOR sie versteckt wird.
function captureSourceSnapshot(el) {
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return null;
const cs = window.getComputedStyle(el);
return {
rect,
fontSize: cs.fontSize,
fontFamily: cs.fontFamily,
fontWeight: cs.fontWeight,
color: cs.color,
text: el.textContent,
};
}
function flyDownFromSnapshot(srcSnap, destEl) {
if (!srcSnap || !destEl) return;
const dstRect = destEl.getBoundingClientRect();
if (dstRect.width === 0 || dstRect.height === 0) return;
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
// setzen die Geschwister visuell an die Position, die sie VOR
// dem neuen Eintrag hatten (eine Slot-Höhe nach oben). Dann
// gleiten sie animiert in ihre natürliche Position herunter.
const container = destEl.parentElement;
const siblings = container
? Array.from(
container.querySelectorAll(".leaderboard-entry")
).filter((e) => e !== destEl)
: [];
let shiftPx = dstRect.height;
if (container) {
const cs = window.getComputedStyle(container);
const gap =
parseFloat(cs.rowGap) || parseFloat(cs.gap) || 0;
shiftPx += gap;
}
// Dest sofort verstecken (Layout-Slot bleibt erhalten)
destEl.style.visibility = "hidden";
// Geschwister hochsetzen (instant, ohne Transition)
siblings.forEach((s) => {
s.style.transition = "none";
s.style.transform = `translateY(-${shiftPx}px)`;
});
// Reflow, damit der "instant"-Setup wirkt
if (siblings.length > 0) siblings[0].getBoundingClientRect();
// Slide nach unten zur natürlichen Position
const slideMs = 280;
siblings.forEach((s) => {
s.style.transition = `transform ${slideMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
s.style.transform = "translateY(0)";
});
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
const flyMs = 800;
setTimeout(() => {
const ghost = document.createElement("div");
ghost.textContent = srcSnap.text;
ghost.style.position = "fixed";
ghost.style.left = srcSnap.rect.left + "px";
ghost.style.top = srcSnap.rect.top + "px";
ghost.style.width = srcSnap.rect.width + "px";
ghost.style.height = srcSnap.rect.height + "px";
ghost.style.margin = "0";
ghost.style.padding = "0";
ghost.style.zIndex = "9999";
ghost.style.pointerEvents = "none";
ghost.style.color = srcSnap.color;
ghost.style.fontFamily = srcSnap.fontFamily;
ghost.style.fontWeight = srcSnap.fontWeight;
ghost.style.fontSize = srcSnap.fontSize;
ghost.style.lineHeight = "1";
ghost.style.display = "flex";
ghost.style.alignItems = "center";
ghost.style.justifyContent = "center";
ghost.style.textShadow = "0 0 12px rgba(0, 255, 136, 0.8)";
ghost.style.borderRadius = "8px";
ghost.style.transition =
`left ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`top ${flyMs}ms cubic-bezier(0.55, 0.05, 0.3, 1.1),` +
`width ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`height ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`font-size ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
`color ${flyMs}ms ease-out,` +
`text-shadow ${flyMs}ms ease-out`;
document.body.appendChild(ghost);
// Reflow erzwingen, damit die Anfangsposition wirkt
ghost.getBoundingClientRect();
// Endwerte: aktuelle Position des .time-Spans im Eintrag
const destTimeSpan = destEl.querySelector(".time") || destEl;
const destTimeRect = destTimeSpan.getBoundingClientRect();
const destStyle = window.getComputedStyle(destTimeSpan);
ghost.style.left = destTimeRect.left + "px";
ghost.style.top = destTimeRect.top + "px";
ghost.style.width = destTimeRect.width + "px";
ghost.style.height = destTimeRect.height + "px";
ghost.style.fontSize = destStyle.fontSize;
ghost.style.color = destStyle.color;
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
setTimeout(() => {
ghost.remove();
destEl.style.visibility = "";
// Sibling-Transforms aufräumen (sie sind eh schon bei 0)
siblings.forEach((s) => {
s.style.transition = "";
s.style.transform = "";
});
}, flyMs + 20);
}, slideMs);
}
// Bestimmt das Ziel-Element (erster Leaderboard-Eintrag) für eine Lane.
function findFlyDest(lane) {
const containerId =
leaderboardData && leaderboardData.mode === "different"
? "leaderboard-container-" + lane
: "leaderboard-container-1";
const container = document.getElementById(containerId);
if (!container) return null;
return container.querySelector(".leaderboard-entry");
}
function updateLeaderboardDisplay() {
const box1 = document.getElementById("best-times-1");
const box2 = document.getElementById("best-times-2");
@@ -827,6 +965,11 @@
.then((data) => {
timer1 = data.time1;
timer2 = data.time2;
// Alte Status-Werte sichern, BEVOR sie überschrieben werden
const oldStatus1 = status1;
const oldStatus2 = status2;
// Status nur übernehmen, wenn der Wert gültig ist.
// Bei unvollständiger ESP-Response (Last) bleibt der
// bisherige Status erhalten statt "Status unbekannt".
@@ -834,10 +977,43 @@
if (validStatuses.includes(data.status2)) status2 = data.status2;
best1 = data.best1;
best2 = data.best2;
// TTS: bei Übergang running -> finished die Endzeit ansagen
// ("Neue Zeit: ..."). Bahn-/Status-Phrasen werden bewusst
// weggelassen.
if (window.tts && tts.isEnabled()) {
if (oldStatus1 === 'running' && status1 === 'finished' && data.time1 > 0) {
tts.sayTime(data.time1);
}
if (oldStatus2 === 'running' && status2 === 'finished' && data.time2 > 0) {
tts.sayTime(data.time2);
}
}
learningMode = data.learningMode;
learningButton = data.learningButton || "";
lastSync = Date.now();
// Übergang finished -> ready erkennen.
// Snapshot der großen Zeit JETZT einfrieren, bevor
// kickDisplayScheduler/updateDisplay sie versteckt.
const fly1 =
oldStatus1 === "finished" && status1 === "ready"
? captureSourceSnapshot(document.getElementById("time1"))
: null;
const fly2 =
oldStatus2 === "finished" && status2 === "ready"
? captureSourceSnapshot(document.getElementById("time2"))
: null;
kickDisplayScheduler();
// Animation auf nächsten Frame, wenn updateDisplay durch ist
if (fly1 || fly2) {
requestAnimationFrame(() => {
if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1));
if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2));
});
}
})
.catch((error) =>
console.error("Fehler beim Laden deiner Daten:", error)

View File

@@ -9,6 +9,7 @@
<!-- Stylesheets -->
<link rel="stylesheet" href="leaderboard.css" />
<title>Ninjacross Timer - Leaderboard</title>
<script src="/tts.js" defer></script>
</head>
<body>
<!-- Modern Notification Toast -->
@@ -54,6 +55,10 @@
<script>
let leaderboardData = [];
let lastUpdateTime = null;
// Identität (name + Zeit) des aktuellen Top-1-Eintrags. Wechselt
// diese, sagen wir „neue Bestzeit + Zeit" an. Initial null →
// beim allerersten Poll wird nur registriert, nicht angesagt.
let lastTopId = null;
// Seite laden
window.onload = function () {
@@ -67,15 +72,31 @@
try {
const response = await fetch("/api/leaderboard-full");
const data = await response.json();
const wasFirstLoad = leaderboardData.length === 0 && lastTopId === null;
leaderboardData = data.leaderboard || [];
lastUpdateTime = new Date();
updateLeaderboardDisplay();
announceTopIfChanged(wasFirstLoad);
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
showMessage("Fehler beim Laden des Leaderboards", "error");
}
}
// Wechselt der Top-1-Eintrag, kommt eine TTS-Ansage ("Neue Zeit
// + Zeit"). Beim allerersten Laden nur den Stand merken, sonst
// würde jeder Seitenaufruf die aktuelle Bestzeit erneut ansagen.
function announceTopIfChanged(isFirstLoad) {
const top = leaderboardData[0];
const newId = top ? `${top.name}::${top.timeFormatted}` : null;
if (lastTopId !== newId) {
if (!isFirstLoad && newId && window.tts && tts.isEnabled()) {
tts.sayTime(tts.parseFormattedTime(top.timeFormatted));
}
lastTopId = newId;
}
}
// Leaderboard anzeigen
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");

308
data/tts.js Normal file
View File

@@ -0,0 +1,308 @@
// tts.js — Plays pre-rendered MP3 snippets from /tts/ gaplessly.
// Uses the Web Audio API: each MP3 is decoded once into an AudioBuffer,
// then chained playback is scheduled with start(when) so there is no
// JS-callback gap between snippets. Persists enable-state in
// localStorage. The toggle button doubles as the user gesture that
// unlocks the AudioContext on browsers with autoplay restrictions
// (iOS Safari, many SmartTV browsers).
(() => {
'use strict';
const BASE = '/tts/';
const STORAGE_KEY = 'aqm_tts_enabled';
// All snippets shipped in /tts/. Listed explicitly so we can
// preload (and decode) them upfront before the first announcement.
// Numbers 0-99 are spoken as natural German words ("vierzehn",
// "sechsundneunzig"), produced by tools/generate-tts.py.
const FILES = [
'bereit', 'komma', 'minute', 'minuten',
'neue_zeit', 'sekunden', 'und',
];
for (let i = 0; i < 100; i++) FILES.push(String(i));
let enabled = localStorage.getItem(STORAGE_KEY) === '1';
let audioCtx = null;
// For each name we cache { buffer, offset, duration } where offset
// and duration mark the non-silent region of the decoded buffer.
// Edge-TTS pads each MP3 with ~50-150 ms of leading/trailing
// silence, which would otherwise stack up between snippets.
const buffers = Object.create(null);
let scheduled = [];
let preloadPromise = null;
let btn = null;
// Linear amplitude threshold below which a sample counts as silence
// (~ -46 dBFS). Slightly higher than typical decoder noise floor.
const SILENCE_THRESHOLD = 0.005;
// Tiny grace at the start so we don't chop a soft consonant attack.
const LEAD_GRACE_S = 0.01;
// Scans both channels of an AudioBuffer to find the first and last
// sample whose absolute value exceeds SILENCE_THRESHOLD, returning
// {offset, duration} in seconds for use with start(when, offset,
// duration). Falls back to the full buffer if everything looks
// silent (shouldn't happen for our snippets, but be safe).
function findNonSilentRange(buffer) {
const channels = buffer.numberOfChannels;
const len = buffer.length;
const sampleRate = buffer.sampleRate;
let firstHit = len;
let lastHit = -1;
for (let c = 0; c < channels; c++) {
const data = buffer.getChannelData(c);
for (let i = 0; i < firstHit; i++) {
if (Math.abs(data[i]) >= SILENCE_THRESHOLD) {
firstHit = i;
break;
}
}
for (let i = len - 1; i > lastHit; i--) {
if (Math.abs(data[i]) >= SILENCE_THRESHOLD) {
lastHit = i;
break;
}
}
}
if (lastHit < 0 || firstHit >= len) {
return { offset: 0, duration: buffer.duration };
}
const grace = Math.floor(LEAD_GRACE_S * sampleRate);
const start = Math.max(0, firstHit - grace);
const end = Math.min(len, lastHit + 1);
return {
offset: start / sampleRate,
duration: (end - start) / sampleRate,
};
}
// Builds the spoken-snippet sequence for a duration in seconds.
// Examples (all sound natural in German):
// 14.96 -> ["14","komma","96","sekunden"] "vierzehn Komma sechsundneunzig Sekunden"
// 14.05 -> ["14","komma","0","5","sekunden"] "vierzehn Komma null fünf Sekunden"
// 14.00 -> ["14","sekunden"] "vierzehn Sekunden"
// 65.96 -> ["minute","und","5","komma","96","sekunden"]
// "eine Minute und fünf Komma sechsundneunzig Sekunden"
// 125.50 -> ["2","minuten","und","5","komma","50","sekunden"]
//
// Note on hundredths < 10: the leading zero is spoken digit-by-
// digit ("null fünf" for .05) so .05 stays distinguishable from
// .50 ("fünfzig"). For >= 10 the value is spoken as a single
// German word ("sechsundneunzig" for .96).
function timeToSeq(seconds) {
const total = Math.max(0, Number(seconds) || 0);
// Replicate the server's exact float-based formatting so the
// announcement always matches what the user sees on screen.
// The ESP (databasebackend.h, gamemodes.h) does:
// float s = timeMs / 1000.0;
// int totalSec = (int)s;
// int hundredths = (int)((s - totalSec) * 100);
// C++ `float` is single precision (24-bit mantissa); JS Number
// is double precision. For times near a hundredths boundary
// (e.g. 14.090) the two give different floor results — server
// says "14.08" but a naive double calculation announces "14.09".
// Math.fround forces single-precision rounding at each step so
// the chain matches the server bit-for-bit.
const sFloat = Math.fround(total);
const totalSec = Math.trunc(sFloat);
const minutes = Math.floor(totalSec / 60);
const remSec = totalSec % 60;
const diffFloat = Math.fround(sFloat - totalSec);
const scaledFloat = Math.fround(diffFloat * 100);
let hundredths = Math.trunc(scaledFloat);
if (hundredths < 0) hundredths = 0;
if (hundredths > 99) hundredths = 99;
const out = [];
if (minutes > 0) {
if (minutes === 1) {
out.push('minute'); // "eine Minute"
} else {
out.push(String(minutes));
out.push('minuten');
}
if (remSec > 0 || hundredths > 0) out.push('und');
}
if (remSec > 0 || hundredths > 0 || minutes === 0) {
out.push(String(remSec));
if (hundredths > 0) {
out.push('komma');
if (hundredths < 10) {
out.push('0');
out.push(String(hundredths));
} else {
out.push(String(hundredths));
}
}
out.push('sekunden');
}
return out;
}
// "12.34" or "01:23.45" -> seconds. timeToSeq() then re-rounds
// the value through Math.fround to match the server's float math,
// so the parseFloat drift is harmless here.
function parseFormattedTime(str) {
if (!str) return 0;
if (str.includes(':')) {
const [mm, rest] = str.split(':');
return parseInt(mm, 10) * 60 + parseFloat(rest);
}
return parseFloat(str) || 0;
}
function ensureContext() {
if (audioCtx) return;
const Ctx = window.AudioContext || window.webkitAudioContext;
if (!Ctx) {
console.warn('TTS: Web Audio API not supported');
return;
}
audioCtx = new Ctx();
}
// Limit concurrent fetches: the ESP's async web server only serves
// a handful of requests well in parallel, and the browser's 6-per-
// host pool would otherwise starve the 1 s /api/data poll while
// 107 MP3s come in. Two parallel fetches finishes the preload in
// ~2 s without holding up the live timer.
const PRELOAD_CONCURRENCY = 2;
async function fetchAndStore(name) {
const res = await fetch(BASE + name + '.mp3');
const arr = await res.arrayBuffer();
// Older Safari only supports the callback form of decodeAudioData.
const buffer = await new Promise((resolve, reject) => {
const p = audioCtx.decodeAudioData(arr, resolve, reject);
if (p && typeof p.then === 'function') p.then(resolve, reject);
});
const { offset, duration } = findNonSilentRange(buffer);
buffers[name] = { buffer, offset, duration };
}
function preload() {
if (preloadPromise) return preloadPromise;
ensureContext();
if (!audioCtx) return Promise.resolve();
let i = 0;
const worker = async () => {
while (i < FILES.length) {
const name = FILES[i++];
try {
await fetchAndStore(name);
} catch (e) {
console.warn('TTS preload failed:', name, e);
}
}
};
const workers = [];
for (let n = 0; n < PRELOAD_CONCURRENCY; n++) workers.push(worker());
preloadPromise = Promise.all(workers);
return preloadPromise;
}
function stop() {
scheduled.forEach(s => { try { s.stop(); } catch (_) {} });
scheduled = [];
}
function play(seq) {
if (!enabled || !seq || !seq.length) return;
ensureContext();
if (!audioCtx) return;
// Browsers may suspend the context until a user gesture. resume()
// is a no-op if already running.
if (audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
stop();
let when = audioCtx.currentTime;
seq.forEach((name) => {
const entry = buffers[name];
if (!entry) {
console.warn('TTS buffer missing:', name);
return;
}
const src = audioCtx.createBufferSource();
src.buffer = entry.buffer;
src.connect(audioCtx.destination);
// start(when, offset, duration) plays only the non-silent slice
// we identified during preload, so trailing silence in each
// snippet doesn't stack up between words.
src.start(when, entry.offset, entry.duration);
scheduled.push(src);
when += entry.duration;
});
}
function setEnabled(on) {
enabled = !!on;
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0');
if (!enabled) stop();
updateToggleUI();
}
function updateToggleUI() {
if (!btn) return;
btn.textContent = enabled ? '🔊' : '🔇';
btn.title = enabled ? 'Ansagen deaktivieren' : 'Ansagen aktivieren';
btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
}
function injectToggle() {
if (btn || !document.body) return;
btn = document.createElement('button');
btn.id = 'tts-toggle';
btn.style.cssText =
'position:fixed;bottom:14px;right:14px;z-index:9998;' +
'width:54px;height:54px;border-radius:50%;border:none;' +
'background:rgba(0,0,0,0.55);color:#fff;font-size:24px;' +
'cursor:pointer;display:flex;align-items:center;justify-content:center;' +
'box-shadow:0 2px 10px rgba(0,0,0,0.35);';
btn.addEventListener('click', async () => {
const next = !enabled;
setEnabled(next);
if (next) {
// The click itself is the user gesture that lets us create
// and resume the AudioContext. Preload then play a short ack.
await preload();
play(['bereit']);
}
});
document.body.appendChild(btn);
updateToggleUI();
}
// Decode buffers in the background. On a fresh page load the
// AudioContext is created in suspended state (no audio yet, just
// decoding — allowed without a gesture), so the first real
// announcement after the user clicks anywhere is already gapless.
// Defer the start so the initial render and the first /api/data
// poll go through unimpeded — otherwise the ESP's web server is
// busy serving MP3s and the live timer freezes for several seconds.
function eagerPreload() {
if (!enabled) return;
setTimeout(preload, 2000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
injectToggle();
eagerPreload();
});
} else {
injectToggle();
eagerPreload();
}
window.tts = {
isEnabled: () => enabled,
setEnabled,
play,
stop,
timeToSeq,
parseFormattedTime,
sayTime: (sec) => play(['neue_zeit', ...timeToSeq(sec)]),
};
})();

BIN
data/tts/0.mp3 Normal file

Binary file not shown.

BIN
data/tts/1.mp3 Normal file

Binary file not shown.

BIN
data/tts/10.mp3 Normal file

Binary file not shown.

BIN
data/tts/11.mp3 Normal file

Binary file not shown.

BIN
data/tts/12.mp3 Normal file

Binary file not shown.

BIN
data/tts/13.mp3 Normal file

Binary file not shown.

BIN
data/tts/14.mp3 Normal file

Binary file not shown.

BIN
data/tts/15.mp3 Normal file

Binary file not shown.

BIN
data/tts/16.mp3 Normal file

Binary file not shown.

BIN
data/tts/17.mp3 Normal file

Binary file not shown.

BIN
data/tts/18.mp3 Normal file

Binary file not shown.

BIN
data/tts/19.mp3 Normal file

Binary file not shown.

BIN
data/tts/2.mp3 Normal file

Binary file not shown.

BIN
data/tts/20.mp3 Normal file

Binary file not shown.

BIN
data/tts/21.mp3 Normal file

Binary file not shown.

BIN
data/tts/22.mp3 Normal file

Binary file not shown.

BIN
data/tts/23.mp3 Normal file

Binary file not shown.

BIN
data/tts/24.mp3 Normal file

Binary file not shown.

BIN
data/tts/25.mp3 Normal file

Binary file not shown.

BIN
data/tts/26.mp3 Normal file

Binary file not shown.

BIN
data/tts/27.mp3 Normal file

Binary file not shown.

BIN
data/tts/28.mp3 Normal file

Binary file not shown.

BIN
data/tts/29.mp3 Normal file

Binary file not shown.

BIN
data/tts/3.mp3 Normal file

Binary file not shown.

BIN
data/tts/30.mp3 Normal file

Binary file not shown.

BIN
data/tts/31.mp3 Normal file

Binary file not shown.

BIN
data/tts/32.mp3 Normal file

Binary file not shown.

BIN
data/tts/33.mp3 Normal file

Binary file not shown.

BIN
data/tts/34.mp3 Normal file

Binary file not shown.

BIN
data/tts/35.mp3 Normal file

Binary file not shown.

BIN
data/tts/36.mp3 Normal file

Binary file not shown.

BIN
data/tts/37.mp3 Normal file

Binary file not shown.

BIN
data/tts/38.mp3 Normal file

Binary file not shown.

BIN
data/tts/39.mp3 Normal file

Binary file not shown.

BIN
data/tts/4.mp3 Normal file

Binary file not shown.

BIN
data/tts/40.mp3 Normal file

Binary file not shown.

BIN
data/tts/41.mp3 Normal file

Binary file not shown.

BIN
data/tts/42.mp3 Normal file

Binary file not shown.

BIN
data/tts/43.mp3 Normal file

Binary file not shown.

BIN
data/tts/44.mp3 Normal file

Binary file not shown.

BIN
data/tts/45.mp3 Normal file

Binary file not shown.

BIN
data/tts/46.mp3 Normal file

Binary file not shown.

BIN
data/tts/47.mp3 Normal file

Binary file not shown.

BIN
data/tts/48.mp3 Normal file

Binary file not shown.

BIN
data/tts/49.mp3 Normal file

Binary file not shown.

BIN
data/tts/5.mp3 Normal file

Binary file not shown.

BIN
data/tts/50.mp3 Normal file

Binary file not shown.

BIN
data/tts/51.mp3 Normal file

Binary file not shown.

BIN
data/tts/52.mp3 Normal file

Binary file not shown.

BIN
data/tts/53.mp3 Normal file

Binary file not shown.

BIN
data/tts/54.mp3 Normal file

Binary file not shown.

BIN
data/tts/55.mp3 Normal file

Binary file not shown.

BIN
data/tts/56.mp3 Normal file

Binary file not shown.

BIN
data/tts/57.mp3 Normal file

Binary file not shown.

BIN
data/tts/58.mp3 Normal file

Binary file not shown.

BIN
data/tts/59.mp3 Normal file

Binary file not shown.

BIN
data/tts/6.mp3 Normal file

Binary file not shown.

BIN
data/tts/60.mp3 Normal file

Binary file not shown.

BIN
data/tts/61.mp3 Normal file

Binary file not shown.

BIN
data/tts/62.mp3 Normal file

Binary file not shown.

BIN
data/tts/63.mp3 Normal file

Binary file not shown.

BIN
data/tts/64.mp3 Normal file

Binary file not shown.

BIN
data/tts/65.mp3 Normal file

Binary file not shown.

BIN
data/tts/66.mp3 Normal file

Binary file not shown.

BIN
data/tts/67.mp3 Normal file

Binary file not shown.

BIN
data/tts/68.mp3 Normal file

Binary file not shown.

BIN
data/tts/69.mp3 Normal file

Binary file not shown.

BIN
data/tts/7.mp3 Normal file

Binary file not shown.

BIN
data/tts/70.mp3 Normal file

Binary file not shown.

BIN
data/tts/71.mp3 Normal file

Binary file not shown.

BIN
data/tts/72.mp3 Normal file

Binary file not shown.

BIN
data/tts/73.mp3 Normal file

Binary file not shown.

BIN
data/tts/74.mp3 Normal file

Binary file not shown.

BIN
data/tts/75.mp3 Normal file

Binary file not shown.

BIN
data/tts/76.mp3 Normal file

Binary file not shown.

BIN
data/tts/77.mp3 Normal file

Binary file not shown.

BIN
data/tts/78.mp3 Normal file

Binary file not shown.

BIN
data/tts/79.mp3 Normal file

Binary file not shown.

BIN
data/tts/8.mp3 Normal file

Binary file not shown.

BIN
data/tts/80.mp3 Normal file

Binary file not shown.

BIN
data/tts/81.mp3 Normal file

Binary file not shown.

BIN
data/tts/82.mp3 Normal file

Binary file not shown.

BIN
data/tts/83.mp3 Normal file

Binary file not shown.

BIN
data/tts/84.mp3 Normal file

Binary file not shown.

BIN
data/tts/85.mp3 Normal file

Binary file not shown.

BIN
data/tts/86.mp3 Normal file

Binary file not shown.

BIN
data/tts/87.mp3 Normal file

Binary file not shown.

BIN
data/tts/88.mp3 Normal file

Binary file not shown.

BIN
data/tts/89.mp3 Normal file

Binary file not shown.

BIN
data/tts/9.mp3 Normal file

Binary file not shown.

BIN
data/tts/90.mp3 Normal file

Binary file not shown.

BIN
data/tts/91.mp3 Normal file

Binary file not shown.

BIN
data/tts/92.mp3 Normal file

Binary file not shown.

BIN
data/tts/93.mp3 Normal file

Binary file not shown.

BIN
data/tts/94.mp3 Normal file

Binary file not shown.

BIN
data/tts/95.mp3 Normal file

Binary file not shown.

BIN
data/tts/96.mp3 Normal file

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More