diff --git a/.env.example b/.env.example index 03f8e53..0067843 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,8 @@ GOOGLE_API_KEY= # Mistral Voxtral TTS — https://console.mistral.ai/api-keys # MISTRAL_API_KEY= +# Mistral voice ID — maak een stem aan op https://console.mistral.ai en plak het ID hier +# MISTRAL_VOICE_ID= # OpenAI TTS-1 — https://platform.openai.com/api-keys # OPENAI_API_KEY= diff --git a/docker-compose.yml b/docker-compose.yml index 7ee6978..f25613e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,11 +3,11 @@ services: # image: git.hiddenden.cafe/hiddenden/cozy-den:latest build: . container_name: cozy-den - # ports: - # - "3000:3000" + ports: + - "3000:3000" restart: unless-stopped - networks: - - proxy + # networks: + # - proxy environment: - NODE_ENV=production - HOST=0.0.0.0 @@ -22,6 +22,6 @@ services: volumes: guestbook_data: -networks: - proxy: - external: true +# networks: +# proxy: +# external: true diff --git a/public/audio/after-the-silence.mp3 b/public/audio/after-the-silence.mp3 new file mode 100644 index 0000000..70fae6c Binary files /dev/null and b/public/audio/after-the-silence.mp3 differ diff --git a/public/audio/between-scores-and-self.mp3 b/public/audio/between-scores-and-self.mp3 new file mode 100644 index 0000000..0c6109a Binary files /dev/null and b/public/audio/between-scores-and-self.mp3 differ diff --git a/public/audio/building-things-after-loss.mp3 b/public/audio/building-things-after-loss.mp3 new file mode 100644 index 0000000..7fc0f80 Binary files /dev/null and b/public/audio/building-things-after-loss.mp3 differ diff --git a/public/audio/coffee-and-code-1-building-quiet-corners-on-the-internet.mp3 b/public/audio/coffee-and-code-1-building-quiet-corners-on-the-internet.mp3 new file mode 100644 index 0000000..fe39975 Binary files /dev/null and b/public/audio/coffee-and-code-1-building-quiet-corners-on-the-internet.mp3 differ diff --git a/public/audio/hello-world.mp3 b/public/audio/hello-world.mp3 new file mode 100644 index 0000000..5c9180b Binary files /dev/null and b/public/audio/hello-world.mp3 differ diff --git a/public/audio/love-without-access.mp3 b/public/audio/love-without-access.mp3 new file mode 100644 index 0000000..3531f50 Binary files /dev/null and b/public/audio/love-without-access.mp3 differ diff --git a/public/audio/rebuilding-without-rushing.mp3 b/public/audio/rebuilding-without-rushing.mp3 new file mode 100644 index 0000000..18d00e5 Binary files /dev/null and b/public/audio/rebuilding-without-rushing.mp3 differ diff --git a/public/audio/shared-worlds.mp3 b/public/audio/shared-worlds.mp3 new file mode 100644 index 0000000..34490e7 Binary files /dev/null and b/public/audio/shared-worlds.mp3 differ diff --git a/public/audio/still-listed.mp3 b/public/audio/still-listed.mp3 new file mode 100644 index 0000000..e4ea312 Binary files /dev/null and b/public/audio/still-listed.mp3 differ diff --git a/public/audio/the-futures-that-quietly-disappeared.mp3 b/public/audio/the-futures-that-quietly-disappeared.mp3 new file mode 100644 index 0000000..7de71ec Binary files /dev/null and b/public/audio/the-futures-that-quietly-disappeared.mp3 differ diff --git a/public/audio/the-quiet-luxury-of-self-hosting.mp3 b/public/audio/the-quiet-luxury-of-self-hosting.mp3 new file mode 100644 index 0000000..b7b3bb6 Binary files /dev/null and b/public/audio/the-quiet-luxury-of-self-hosting.mp3 differ diff --git a/public/audio/the-things-we-never-got-to-do.mp3 b/public/audio/the-things-we-never-got-to-do.mp3 new file mode 100644 index 0000000..c6e7f79 Binary files /dev/null and b/public/audio/the-things-we-never-got-to-do.mp3 differ diff --git a/public/audio/things-i-learned-from-loving-deeply.mp3 b/public/audio/things-i-learned-from-loving-deeply.mp3 new file mode 100644 index 0000000..ecdf8b1 Binary files /dev/null and b/public/audio/things-i-learned-from-loving-deeply.mp3 differ diff --git a/public/audio/unexpected-variables.mp3 b/public/audio/unexpected-variables.mp3 new file mode 100644 index 0000000..063c16c Binary files /dev/null and b/public/audio/unexpected-variables.mp3 differ diff --git a/scripts/tts_generate.py b/scripts/tts_generate.py index 2023375..19ebe3f 100644 --- a/scripts/tts_generate.py +++ b/scripts/tts_generate.py @@ -6,8 +6,8 @@ Usage: python scripts/tts_generate.py src/content/blog/my-post.md Environment variables: - TTS_PROVIDER - "google" (default), "mistral", or "openai" - GOOGLE_API_KEY - Required when TTS_PROVIDER=google + TTS_PROVIDER - "google" (default), "mistral", or "openai" + GOOGLE_API_KEY - Required when TTS_PROVIDER=google MISTRAL_API_KEY - Required when TTS_PROVIDER=mistral OPENAI_API_KEY - Required when TTS_PROVIDER=openai @@ -21,6 +21,23 @@ import re import sys +def load_dotenv() -> None: + """Load .env from the project root into os.environ (stdlib only, never overwrites).""" + project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + env_path = os.path.join(project_root, ".env") + if not os.path.isfile(env_path): + return + with open(env_path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + def parse_frontmatter(text: str) -> tuple[dict, str]: """Extract YAML frontmatter and return (metadata_dict, body).""" if not text.startswith("---"): @@ -31,7 +48,7 @@ def parse_frontmatter(text: str) -> tuple[dict, str]: return {}, text front = text[3:end].strip() - body = text[end + 4:].strip() + body = text[end + 4 :].strip() meta: dict = {} for line in front.splitlines(): @@ -69,99 +86,195 @@ def clean_markdown(text: str) -> str: return text.strip() -def tts_google(text: str, slug: str, output_path: str) -> None: - """Generate audio with Google Cloud TTS (free tier: 1M chars/month).""" +def split_into_chunks(text: str, max_chars: int = 4000) -> list[str]: + """Split text into chunks that fit within max_chars. + + Splits on paragraph boundaries first; falls back to sentence boundaries + for paragraphs that are still too long. No text is ever discarded. + """ + paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()] + + # Flatten into atomic segments (paragraphs or individual sentences) + segments: list[str] = [] + for para in paragraphs: + if len(para) <= max_chars: + segments.append(para) + else: + sentences = re.split(r"(?<=[.!?])\s+", para) + segments.extend(s for s in sentences if s.strip()) + + # Merge segments greedily into chunks under max_chars + chunks: list[str] = [] + current = "" + for segment in segments: + if not current: + current = segment + elif len(current) + 2 + len(segment) <= max_chars: + current += "\n\n" + segment + else: + chunks.append(current) + current = segment + if current: + chunks.append(current) + + return chunks + + +def merge_audio_chunks(chunks: list[bytes], output_path: str) -> None: + """Concatenate MP3 byte chunks with a ~400ms silent pause between each. + + MP3 frames are self-contained, so byte concatenation produces a valid file. + Silent frame: 128kbps/44100Hz frame header + null payload = 417 bytes. + 16 frames * ~26.1ms each ≈ 418ms of silence. + """ + silent_frame = b"\xff\xfb\x90\x00" + b"\x00" * 413 # 417 bytes + silence = silent_frame * 16 + + with open(output_path, "wb") as f: + for i, chunk in enumerate(chunks): + f.write(chunk) + if i < len(chunks) - 1: + f.write(silence) + + +# --------------------------------------------------------------------------- +# Provider helpers — return raw MP3 bytes for a single text chunk +# --------------------------------------------------------------------------- + + +def _google_synthesize(text: str, api_key: str) -> bytes: + import base64 import json import urllib.request + payload = json.dumps( + { + "input": {"text": text}, + "voice": { + "languageCode": "nl-NL", + "name": "nl-NL-Wavenet-D", + "ssmlGender": "MALE", + }, + "audioConfig": {"audioEncoding": "MP3"}, + } + ).encode() + + url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}" + req = urllib.request.Request( + url, data=payload, headers={"Content-Type": "application/json"} + ) + with urllib.request.urlopen(req) as resp: + body = json.loads(resp.read()) + + return base64.b64decode(body["audioContent"]) + + +def _mistral_synthesize(text: str, api_key: str) -> bytes: + import base64 + import json + import urllib.error + import urllib.request + + body: dict = { + "model": "voxtral-mini-tts-2603", + "input": text, + "response_format": "mp3", + } + voice_id = os.environ.get("MISTRAL_VOICE_ID", "").strip() + if not voice_id: + raise EnvironmentError( + "MISTRAL_VOICE_ID is not set. " + "Create a voice at https://console.mistral.ai and add its ID to .env" + ) + body["voice"] = voice_id + + req = urllib.request.Request( + "https://api.mistral.ai/v1/audio/speech", + data=json.dumps(body).encode(), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + return base64.b64decode(data["audio_data"]) + except urllib.error.HTTPError as exc: + detail = exc.read().decode("utf-8", errors="replace") + raise RuntimeError(f"Mistral API {exc.code}: {detail}") from exc + + +def _openai_synthesize(text: str, api_key: str) -> bytes: + import json + import urllib.request + + payload = json.dumps( + { + "model": "gpt-4o-mini-tts", + "input": text, + "voice": "ash", + "instructions": "Read aloud in a warm and friendly tone.", + } + ).encode() + + req = urllib.request.Request( + "https://api.openai.com/v1/audio/speech", + data=payload, + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + }, + ) + with urllib.request.urlopen(req) as resp: + return resp.read() + + +# --------------------------------------------------------------------------- +# Public provider functions — chunk, call API per chunk, merge +# --------------------------------------------------------------------------- + + +def tts_google(text: str, slug: str, output_path: str) -> None: + """Generate audio with Google Cloud TTS (free tier: 1M chars/month).""" api_key = os.environ.get("GOOGLE_API_KEY") if not api_key: raise EnvironmentError("GOOGLE_API_KEY is not set") - payload = json.dumps({ - "input": {"text": text}, - "voice": { - "languageCode": "nl-NL", - "name": "nl-NL-Wavenet-D", - "ssmlGender": "FEMALE", - }, - "audioConfig": {"audioEncoding": "MP3"}, - }).encode() - - url = f"https://texttospeech.googleapis.com/v1/text:synthesize?key={api_key}" - req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}) - - with urllib.request.urlopen(req) as resp: - body = json.loads(resp.read()) - - import base64 - audio_bytes = base64.b64decode(body["audioContent"]) - with open(output_path, "wb") as f: - f.write(audio_bytes) + chunks = split_into_chunks(text) + audio_chunks: list[bytes] = [] + for i, chunk in enumerate(chunks, 1): + print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...") + audio_chunks.append(_google_synthesize(chunk, api_key)) + merge_audio_chunks(audio_chunks, output_path) def tts_mistral(text: str, slug: str, output_path: str) -> None: """Generate audio with Mistral Voxtral TTS (~$16/M chars).""" - import json - import urllib.request - api_key = os.environ.get("MISTRAL_API_KEY") if not api_key: raise EnvironmentError("MISTRAL_API_KEY is not set") - payload = json.dumps({ - "model": "voxtral-mini-tts-2507", - "input": text, - "voice": "river", - }).encode() - - url = "https://api.mistral.ai/v1/audio/speech" - req = urllib.request.Request( - url, - data=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - }, - ) - - with urllib.request.urlopen(req) as resp: - audio_bytes = resp.read() - - with open(output_path, "wb") as f: - f.write(audio_bytes) + chunks = split_into_chunks(text) + audio_chunks: list[bytes] = [] + for i, chunk in enumerate(chunks, 1): + print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...") + audio_chunks.append(_mistral_synthesize(chunk, api_key)) + merge_audio_chunks(audio_chunks, output_path) def tts_openai(text: str, slug: str, output_path: str) -> None: """Generate audio with OpenAI TTS-1 (~$15/M chars).""" - import json - import urllib.request - api_key = os.environ.get("OPENAI_API_KEY") if not api_key: raise EnvironmentError("OPENAI_API_KEY is not set") - payload = json.dumps({ - "model": "tts-1", - "input": text, - "voice": "nova", - }).encode() - - url = "https://api.openai.com/v1/audio/speech" - req = urllib.request.Request( - url, - data=payload, - headers={ - "Content-Type": "application/json", - "Authorization": f"Bearer {api_key}", - }, - ) - - with urllib.request.urlopen(req) as resp: - audio_bytes = resp.read() - - with open(output_path, "wb") as f: - f.write(audio_bytes) + chunks = split_into_chunks(text) + audio_chunks: list[bytes] = [] + for i, chunk in enumerate(chunks, 1): + print(f" Chunk {i}/{len(chunks)} ({len(chunk)} chars)...") + audio_chunks.append(_openai_synthesize(chunk, api_key)) + merge_audio_chunks(audio_chunks, output_path) PROVIDERS = { @@ -172,9 +285,13 @@ PROVIDERS = { def main() -> None: + load_dotenv() + parser = argparse.ArgumentParser(description="Generate TTS audio for a blog post") parser.add_argument("file", help="Path to the .md blog post") - parser.add_argument("--output-dir", default=".", help="Directory to write the .mp3 (default: .)") + parser.add_argument( + "--output-dir", default=".", help="Directory to write the .mp3 (default: .)" + ) args = parser.parse_args() md_path = args.file @@ -190,17 +307,21 @@ def main() -> None: slug = meta.get("slug") or os.path.splitext(os.path.basename(md_path))[0] title = meta.get("title", "") - # Prepend title so TTS reads it aloud full_text = f"{title}.\n\n{clean_markdown(body)}" if title else clean_markdown(body) provider_name = os.environ.get("TTS_PROVIDER", "google").lower() if provider_name not in PROVIDERS: - print(f"ERROR: unknown TTS_PROVIDER '{provider_name}'. Choose from: {', '.join(PROVIDERS)}", file=sys.stderr) + print( + f"ERROR: unknown TTS_PROVIDER '{provider_name}'. Choose from: {', '.join(PROVIDERS)}", + file=sys.stderr, + ) sys.exit(1) output_path = os.path.join(args.output_dir, f"{slug}.mp3") - print(f"Generating audio for '{slug}' using provider '{provider_name}' ({len(full_text)} chars)...") + print( + f"Generating audio for '{slug}' using provider '{provider_name}' ({len(full_text)} chars)..." + ) try: PROVIDERS[provider_name](full_text, slug, output_path) diff --git a/src/content/blog/after-the-silence.md b/src/content/blog/after-the-silence.md index c335daa..4e458a2 100644 --- a/src/content/blog/after-the-silence.md +++ b/src/content/blog/after-the-silence.md @@ -1,10 +1,11 @@ ---- +--- title: "After the Silence" description: "Reflections on what remains after a meaningful relationship ends, and how love can transform without disappearing." pubDate: 2026-03-04 tags: ["love", "reflection", "healing", "relationships", "personal"] category: "reflection" featuredEssay: true +audio: true --- When a relationship ends, the world does not become quiet immediately. diff --git a/src/content/blog/between-scores-and-self.md b/src/content/blog/between-scores-and-self.md index 969c426..c08d7e7 100644 --- a/src/content/blog/between-scores-and-self.md +++ b/src/content/blog/between-scores-and-self.md @@ -6,6 +6,7 @@ tags: ["reflection", "identity", "intimacy", "self-knowledge", "personal", "nuan category: "reflection" featuredEssay: false draft: false +audio: true --- *A quiet reflection on BDSM, nuance, and why my answers are rarely simple* diff --git a/src/content/blog/building-things-after-loss.md b/src/content/blog/building-things-after-loss.md index 518e571..82ef652 100644 --- a/src/content/blog/building-things-after-loss.md +++ b/src/content/blog/building-things-after-loss.md @@ -6,6 +6,7 @@ tags: ["reflection", "personal", "internet", "building", "devlog"] category: "reflection" featuredEssay: false draft: false +audio: true --- *by LATTE* diff --git a/src/content/blog/coffee-and-code-1-building-quiet-corners-on-the-internet.md b/src/content/blog/coffee-and-code-1-building-quiet-corners-on-the-internet.md index 55be7f0..6fdb5c3 100644 --- a/src/content/blog/coffee-and-code-1-building-quiet-corners-on-the-internet.md +++ b/src/content/blog/coffee-and-code-1-building-quiet-corners-on-the-internet.md @@ -9,6 +9,7 @@ readingOrder: 2 series: name: "Coffee & Code" part: 1 +audio: true --- The modern internet is loud in a way that can be hard to notice until you step away from it. diff --git a/src/content/blog/hello-world.md b/src/content/blog/hello-world.md index 680801e..c375a21 100644 --- a/src/content/blog/hello-world.md +++ b/src/content/blog/hello-world.md @@ -7,6 +7,7 @@ category: "building" featuredEssay: true readingOrder: 1 draft: false +audio: true --- So I finally got around to setting up a proper blog. Welcome. diff --git a/src/content/blog/love-without-access.md b/src/content/blog/love-without-access.md index 6e8c234..3d29c51 100644 --- a/src/content/blog/love-without-access.md +++ b/src/content/blog/love-without-access.md @@ -1,8 +1,9 @@ ---- +--- title: "Love Without Access" description: "A reflection on a first love - what it meant, what it cost, and why distance was the most loving thing left." pubDate: 2026-03-01 tags: ["love", "reflection", "healing", "relationships", "personal"] +audio: true --- *by LATTE* diff --git a/src/content/blog/rebuilding-without-rushing.md b/src/content/blog/rebuilding-without-rushing.md index 85a0890..fdfafc1 100644 --- a/src/content/blog/rebuilding-without-rushing.md +++ b/src/content/blog/rebuilding-without-rushing.md @@ -3,6 +3,7 @@ title: "Rebuilding Without Rushing" description: "On slowing down after loss, choosing clarity over urgency, and learning that rebuilding does not have to be loud to be real." pubDate: 2026-03-22 tags: ["reflection", "healing", "personal", "rebuilding", "growth"] +audio: true --- ## The idea of rebuilding diff --git a/src/content/blog/shared-worlds.md b/src/content/blog/shared-worlds.md index 2b9aeda..3b49c6f 100644 --- a/src/content/blog/shared-worlds.md +++ b/src/content/blog/shared-worlds.md @@ -6,6 +6,7 @@ tags: ["games", "reflection", "relationships", "internet", "personal"] category: "reflection" featuredEssay: false draft: false +audio: true --- *by LATTE* diff --git a/src/content/blog/still-listed.md b/src/content/blog/still-listed.md index d1edbf5..710cf6c 100644 --- a/src/content/blog/still-listed.md +++ b/src/content/blog/still-listed.md @@ -6,6 +6,7 @@ tags: ["reflection", "personal", "love", "healing"] category: "reflection" featuredEssay: false draft: false +audio: true --- *by LATTE* diff --git a/src/content/blog/the-futures-that-quietly-disappeared.md b/src/content/blog/the-futures-that-quietly-disappeared.md index 5b12e07..df29989 100644 --- a/src/content/blog/the-futures-that-quietly-disappeared.md +++ b/src/content/blog/the-futures-that-quietly-disappeared.md @@ -5,6 +5,7 @@ pubDate: 2026-03-19 tags: ["love", "grief", "reflection", "personal", "relationships"] category: "personal" featuredEssay: false +audio: true --- *by LATTE* diff --git a/src/content/blog/the-quiet-luxury-of-self-hosting.md b/src/content/blog/the-quiet-luxury-of-self-hosting.md index 656acec..d25b823 100644 --- a/src/content/blog/the-quiet-luxury-of-self-hosting.md +++ b/src/content/blog/the-quiet-luxury-of-self-hosting.md @@ -6,6 +6,7 @@ tags: ["self-hosting", "privacy", "digital minimalism", "cozy web", "personal in category: "reflection" featuredEssay: false draft: false +audio: true --- There is a certain kind of quiet that has become rare on the internet. diff --git a/src/content/blog/the-things-we-never-got-to-do.md b/src/content/blog/the-things-we-never-got-to-do.md index 399ce79..4409dc6 100644 --- a/src/content/blog/the-things-we-never-got-to-do.md +++ b/src/content/blog/the-things-we-never-got-to-do.md @@ -6,6 +6,7 @@ tags: ["love", "grief", "reflection", "personal"] category: "personal" featuredEssay: false draft: false +audio: true --- *by LATTE* diff --git a/src/content/blog/things-i-learned-from-loving-deeply.md b/src/content/blog/things-i-learned-from-loving-deeply.md index b2db983..424d9db 100644 --- a/src/content/blog/things-i-learned-from-loving-deeply.md +++ b/src/content/blog/things-i-learned-from-loving-deeply.md @@ -6,6 +6,7 @@ tags: ["love", "relationships", "reflection", "emotional growth", "intimacy"] category: "personal" featuredEssay: true readingOrder: 3 +audio: true --- Some relationships change you in quiet but permanent ways. diff --git a/src/content/blog/unexpected-variables.md b/src/content/blog/unexpected-variables.md index f1d969b..c97be28 100644 --- a/src/content/blog/unexpected-variables.md +++ b/src/content/blog/unexpected-variables.md @@ -4,6 +4,7 @@ description: "On suddenly carrying too much, being seen by the wrong person at t pubDate: 2026-04-04 tags: ["personal", "work", "reflection", "healing", "life"] category: "reflection" +audio: true --- Some weeks arrive with more variables than expected. @@ -17,7 +18,7 @@ And then, quietly, the configuration changes. ## Suddenly, Everything Is Your Problem -I work in tech support. Helpdesk. +I work in tech support - Helpdesk. Hardware. Laptops. Phones. SIM cards. Loaner devices. Accounts across multiple platforms and software. @@ -273,4 +274,4 @@ And smiling a little, quietly, when he says good morning anyway. *More work. More weight. More responsibility.* *And occasionally, more feeling than you had space for.* -*The only thing to do is keep going.* \ No newline at end of file +*The only thing to do is keep going.* diff --git a/src/pages/blog/[...slug].astro b/src/pages/blog/[...slug].astro index 1d33dc5..f47cbe4 100644 --- a/src/pages/blog/[...slug].astro +++ b/src/pages/blog/[...slug].astro @@ -119,17 +119,22 @@ const readingTime = getReadingTime(post.body); { post.data.audio && ( -
- +
+ + +
+ {[38,55,72,88,65,92,78,58,82,96,68,52,86,100,74,60,90,80,63,84,70,94,58,80,74,90,63,84,70,54,80,63,90,74,58,84].map((h, i) => ( + + ))} +
+ 0:00
) } @@ -247,6 +252,33 @@ const readingTime = getReadingTime(post.body);
+