feat: Blog TTS — automatisch blogposts omzetten naar audio via Gitea workflow #72

Closed
opened 2026-04-05 12:44:11 +00:00 by Bartender · 2 comments
Owner

🎙️ Blog Text-to-Speech Pipeline

Samenvatting

Voeg een automatische Text-to-Speech pipeline toe aan Cozy-Den, zodat elke nieuwe blogpost automatisch wordt omgezet naar een .mp3 audiobestand. Het audiobestand wordt gehost op de eigen infrastructuur (nginx/Proxmox) en ingebonden in de Astro blogpost pagina via een <audio> player.


🎯 Doelstelling

  • Bezoekers kunnen blogposts beluisteren via een ingebouwde audio player
  • Audio wordt alleen gegenereerd voor nieuwe posts die nog geen .mp3 hebben
  • Bestaande posts worden niet opnieuw gegenereerd (tenzij handmatig getriggerd)
  • Geen binaire bestanden (.mp3) in de git repo
  • Geen workflow loops

🏗️ Architectuur

Cozy-Den repo (Gitea)
    ↓ push met nieuwe .md blogpost
Gitea Actions workflow (trigger: alleen op src/content/blog/**.md)
    ↓ checkt of mp3 al bestaat op de audio server
    ↓ zo niet → roept Python TTS script aan
    ↓ genereert .mp3 via TTS API (Voxtral of OpenAI)
    ↓ uploadt .mp3 naar audio server via SCP/rsync
Nginx serveert audio.hiddenden.cafe/<slug>.mp3
Astro blogpost layout toont automatisch <audio> player als mp3 bestaat

📋 Taken

1. Python TTS script (scripts/tts_generate.py)

  • Accepteert een .md bestand als input argument
  • Parset frontmatter om de slug en title te lezen
  • Verwijdert markdown opmaak vóór TTS verwerking (headers, links, code blocks, etc.)
  • Roept TTS API aan (Voxtral TTS via Mistral API als eerste keuze — $16/M chars)
  • Slaat output op als <slug>.mp3
  • Geeft duidelijke foutmeldingen bij API errors of ontbrekende bestanden

2. Gitea Actions workflow (.gitea/workflows/tts.yml)

  • Trigger alleen op push naar main met wijzigingen in src/content/blog/**.md
  • Detecteert welke .md bestanden zijn toegevoegd in de laatste commit (niet gewijzigd, alleen nieuw)
  • Checkt per bestand of er al een .mp3 bestaat op de audio server
  • Slaat bestanden over die al een .mp3 hebben
  • Roept tts_generate.py aan voor nieuwe posts
  • Upload gegenereerde .mp3 via SCP/rsync naar Proxmox audio directory
  • Maakt geen commit naar de repo (voorkomt workflow loop)

3. Nginx configuratie (Proxmox)

  • Stel een audio directory in op de server, bijv. /var/www/audio/
  • Configureer nginx om audio.hiddenden.cafe/<slug>.mp3 te serveren
  • Zorg voor correcte MIME types en CORS headers zodat Astro de audio kan inladen

4. Astro integratie

  • Voeg een audio veld toe aan het blog frontmatter schema (optioneel, bijv. audio: true/false)
  • Pas de blogpost layout aan om automatisch een <audio> player te tonen als het mp3 bestand bestaat
  • Gebruik de slug van de post als bestandsnaam om de audio URL te construeren
  • Zorg voor een nette fallback als er geen audio beschikbaar is (geen broken UI)

5. Secrets & configuratie

  • Voeg MISTRAL_API_KEY toe als Gitea Actions secret
  • Voeg SSH credentials toe voor de upload naar Proxmox als Gitea Actions secret
  • Documenteer de benodigde secrets in de README

🔒 Loop preventie

De workflow triggert uitsluitend op .md bestanden:

on:
  push:
    branches: [main]
    paths:
      - 'src/content/blog/**.md'

De gegenereerde .mp3 bestanden worden nooit naar de repo gecommit — ze gaan direct naar de audio server via SCP. Hierdoor kan een mp3 upload nooit een nieuwe workflow trigger veroorzaken.


💰 Kosten inschatting

Een gemiddelde blogpost is 4.000 karakters. Bij Voxtral TTS ($16 per miljoen karakters) kost één blogpost omzetten naar audio **$0,064** — minder dan een cent. Voor tientallen posts per jaar blijft dit volledig verwaarloosbaar.


🔧 Tech stack

Onderdeel Keuze
TTS API Voxtral TTS (Mistral) — goedkoop, naturel, NL ondersteuning
Script Python 3
CI/CD Gitea Actions
Audio hosting Nginx op Proxmox (audio.hiddenden.cafe)
Frontend Astro (blogpost layout component)

📝 Notities

  • Voxtral TTS ondersteunt Nederlands (Dutch) — ideaal voor NL blogposts
  • Als alternatief kan OpenAI TTS-1 worden gebruikt ($15/M chars, vergelijkbare prijs)
  • Het script moet robuust zijn: als de TTS API faalt, moet de workflow niet crashen maar een waarschuwing loggen
  • Overweeg in de toekomst een --force flag aan het script toe te voegen om handmatig een specifieke post opnieuw te genereren
## 🎙️ Blog Text-to-Speech Pipeline ### Samenvatting Voeg een automatische Text-to-Speech pipeline toe aan Cozy-Den, zodat elke nieuwe blogpost automatisch wordt omgezet naar een `.mp3` audiobestand. Het audiobestand wordt gehost op de eigen infrastructuur (nginx/Proxmox) en ingebonden in de Astro blogpost pagina via een `<audio>` player. --- ### 🎯 Doelstelling - Bezoekers kunnen blogposts beluisteren via een ingebouwde audio player - Audio wordt **alleen gegenereerd voor nieuwe posts** die nog geen `.mp3` hebben - Bestaande posts worden **niet opnieuw gegenereerd** (tenzij handmatig getriggerd) - Geen binaire bestanden (`.mp3`) in de git repo - Geen workflow loops --- ### 🏗️ Architectuur ``` Cozy-Den repo (Gitea) ↓ push met nieuwe .md blogpost Gitea Actions workflow (trigger: alleen op src/content/blog/**.md) ↓ checkt of mp3 al bestaat op de audio server ↓ zo niet → roept Python TTS script aan ↓ genereert .mp3 via TTS API (Voxtral of OpenAI) ↓ uploadt .mp3 naar audio server via SCP/rsync Nginx serveert audio.hiddenden.cafe/<slug>.mp3 Astro blogpost layout toont automatisch <audio> player als mp3 bestaat ``` --- ### 📋 Taken #### 1. Python TTS script (`scripts/tts_generate.py`) - [ ] Accepteert een `.md` bestand als input argument - [ ] Parset frontmatter om de `slug` en `title` te lezen - [ ] Verwijdert markdown opmaak vóór TTS verwerking (headers, links, code blocks, etc.) - [ ] Roept TTS API aan (Voxtral TTS via Mistral API als eerste keuze — $16/M chars) - [ ] Slaat output op als `<slug>.mp3` - [ ] Geeft duidelijke foutmeldingen bij API errors of ontbrekende bestanden #### 2. Gitea Actions workflow (`.gitea/workflows/tts.yml`) - [ ] Trigger **alleen** op `push` naar `main` met wijzigingen in `src/content/blog/**.md` - [ ] Detecteert welke `.md` bestanden zijn toegevoegd in de laatste commit (niet gewijzigd, alleen nieuw) - [ ] Checkt per bestand of er al een `.mp3` bestaat op de audio server - [ ] Slaat bestanden over die al een `.mp3` hebben - [ ] Roept `tts_generate.py` aan voor nieuwe posts - [ ] Upload gegenereerde `.mp3` via SCP/rsync naar Proxmox audio directory - [ ] Maakt **geen commit** naar de repo (voorkomt workflow loop) #### 3. Nginx configuratie (Proxmox) - [ ] Stel een audio directory in op de server, bijv. `/var/www/audio/` - [ ] Configureer nginx om `audio.hiddenden.cafe/<slug>.mp3` te serveren - [ ] Zorg voor correcte MIME types en CORS headers zodat Astro de audio kan inladen #### 4. Astro integratie - [ ] Voeg een `audio` veld toe aan het blog frontmatter schema (optioneel, bijv. `audio: true/false`) - [ ] Pas de blogpost layout aan om automatisch een `<audio>` player te tonen als het mp3 bestand bestaat - [ ] Gebruik de `slug` van de post als bestandsnaam om de audio URL te construeren - [ ] Zorg voor een nette fallback als er geen audio beschikbaar is (geen broken UI) #### 5. Secrets & configuratie - [ ] Voeg `MISTRAL_API_KEY` toe als Gitea Actions secret - [ ] Voeg SSH credentials toe voor de upload naar Proxmox als Gitea Actions secret - [ ] Documenteer de benodigde secrets in de README --- ### 🔒 Loop preventie De workflow triggert uitsluitend op `.md` bestanden: ```yaml on: push: branches: [main] paths: - 'src/content/blog/**.md' ``` De gegenereerde `.mp3` bestanden worden **nooit naar de repo gecommit** — ze gaan direct naar de audio server via SCP. Hierdoor kan een mp3 upload nooit een nieuwe workflow trigger veroorzaken. --- ### 💰 Kosten inschatting Een gemiddelde blogpost is ~4.000 karakters. Bij Voxtral TTS ($16 per miljoen karakters) kost één blogpost omzetten naar audio **~$0,064** — minder dan een cent. Voor tientallen posts per jaar blijft dit volledig verwaarloosbaar. --- ### 🔧 Tech stack | Onderdeel | Keuze | |---|---| | TTS API | Voxtral TTS (Mistral) — goedkoop, naturel, NL ondersteuning | | Script | Python 3 | | CI/CD | Gitea Actions | | Audio hosting | Nginx op Proxmox (`audio.hiddenden.cafe`) | | Frontend | Astro (blogpost layout component) | --- ### 📝 Notities - Voxtral TTS ondersteunt Nederlands (Dutch) — ideaal voor NL blogposts - Als alternatief kan OpenAI TTS-1 worden gebruikt ($15/M chars, vergelijkbare prijs) - Het script moet robuust zijn: als de TTS API faalt, moet de workflow niet crashen maar een waarschuwing loggen - Overweeg in de toekomst een `--force` flag aan het script toe te voegen om handmatig een specifieke post opnieuw te genereren
Author
Owner

🔍 TTS Provider vergelijking — Azure & OpenRouter onderzoek

Aanvullend onderzoek naar Azure AI Foundry en OpenRouter als mogelijke TTS providers.


Azure AI Foundry — Kan TTS, maar overkill

Azure Speech in Foundry biedt meer dan 600 stemmen in 150+ talen, inclusief HD neural voices en uitstekende Nederlandse stemmen. Pricing is vergelijkbaar (~$15-16/M chars) en er is een gratis F0 tier.

Nadeel: Azure vereist dat je het volledige Azure ecosysteem instapt — storage, functions, identity management, allemaal apart gefactureerd. Voor een simpel blog-to-audio script is dat veel infrastructurele overhead zonder toegevoegde waarde.


OpenRouter — Niet geschikt voor TTS

OpenRouter ondersteunt audio output via modellen zoals GPT-audio, maar alleen via het chat completions formaat — audio als onderdeel van een conversatie response, niet als dedicated TTS endpoint. Het is omslachtig en duurder dan een provider rechtstreeks aanroepen. OpenRouter is een LLM router, niet een TTS platform.


Provider vergelijking voor deze use case

Dienst NL kwaliteit Prijs Eenvoud
Mistral Voxtral Goed $16/M chars Simpele API
Google Cloud TTS Uitstekend 1M chars/maand gratis Simpel
OpenAI TTS-1 Goed $15/M chars Simpelst
Azure Speech Uitstekend ⚠️ Ecosystem overhead Complex
OpenRouter Niet geschikt Duurder Omslachtig

💡 Aanbeveling: provider-agnostisch script

Het TTS script moet de provider configureerbaar maken via een environment variable, zodat je makkelijk kunt wisselen zonder de code aan te passen:

TTS_PROVIDER=google   # of: mistral, openai

Aanbevolen startpunt: Google Cloud TTS — de gratis tier van 1 miljoen karakters per maand is voor een persoonlijke blog meer dan genoeg, en de Nederlandse stemmen zijn van uitstekende kwaliteit. Later kan altijd geswitcht worden naar Voxtral of OpenAI indien gewenst.

## 🔍 TTS Provider vergelijking — Azure & OpenRouter onderzoek Aanvullend onderzoek naar Azure AI Foundry en OpenRouter als mogelijke TTS providers. --- ### Azure AI Foundry — ✅ Kan TTS, maar overkill Azure Speech in Foundry biedt meer dan 600 stemmen in 150+ talen, inclusief HD neural voices en uitstekende Nederlandse stemmen. Pricing is vergelijkbaar (~$15-16/M chars) en er is een gratis F0 tier. **Nadeel:** Azure vereist dat je het volledige Azure ecosysteem instapt — storage, functions, identity management, allemaal apart gefactureerd. Voor een simpel blog-to-audio script is dat veel infrastructurele overhead zonder toegevoegde waarde. --- ### OpenRouter — ❌ Niet geschikt voor TTS OpenRouter ondersteunt audio output via modellen zoals GPT-audio, maar alleen via het chat completions formaat — audio als onderdeel van een conversatie response, niet als dedicated TTS endpoint. Het is omslachtig en duurder dan een provider rechtstreeks aanroepen. OpenRouter is een LLM router, niet een TTS platform. --- ### Provider vergelijking voor deze use case | Dienst | NL kwaliteit | Prijs | Eenvoud | |---|---|---|---| | **Mistral Voxtral** | ✅ Goed | ✅ $16/M chars | ✅ Simpele API | | **Google Cloud TTS** | ✅ Uitstekend | ✅ 1M chars/maand gratis | ✅ Simpel | | **OpenAI TTS-1** | ✅ Goed | ✅ $15/M chars | ✅ Simpelst | | **Azure Speech** | ✅ Uitstekend | ⚠️ Ecosystem overhead | ❌ Complex | | **OpenRouter** | ❌ Niet geschikt | ❌ Duurder | ❌ Omslachtig | --- ### 💡 Aanbeveling: provider-agnostisch script Het TTS script moet de provider configureerbaar maken via een environment variable, zodat je makkelijk kunt wisselen zonder de code aan te passen: ```env TTS_PROVIDER=google # of: mistral, openai ``` **Aanbevolen startpunt: Google Cloud TTS** — de gratis tier van 1 miljoen karakters per maand is voor een persoonlijke blog meer dan genoeg, en de Nederlandse stemmen zijn van uitstekende kwaliteit. Later kan altijd geswitcht worden naar Voxtral of OpenAI indien gewenst.
Author
Owner

Implementatie — scripts/tts_generate.py bijgewerkt

De volgende fixes zijn doorgevoerd via Claude Code op basis van twee gevonden problemen: env vars werden niet geladen en lange blogposts produceerden slechte audio.


.env loader (load_dotenv, regel 22–33)

  • Zoekt .env twee niveaus boven het script (= project root)
  • Parset KEY=VALUE, skipt comments en lege regels, stript quotes
  • Gebruikt setdefault — shell env vars worden nooit overschreven

Chunking (split_into_chunks, regel 79–103)

  • Splitst eerst op alinea's (\n\n), dan op zinnen ((?<=[.!?])\s+) als een alinea te lang is
  • Voegt segmenten samen zolang ze onder de 4000 chars blijven
  • Gooit nooit tekst weg — volledige blogpost wordt altijd verwerkt

MP3 merge (merge_audio_chunks, regel 106–120)

  • Puur byte-concatenatie — MP3 frames zijn zelfstandig, werkt voor alle players
  • Stopt 16 × 417-byte silent frames tussen chunks (≈418ms pauze)
  • Silent frame: \xff\xfb\x90\x00 + 413 null bytes (geldig 128kbps/44100Hz frame)
  • Geen externe packages — puur stdlib

Provider functies

  • Elk gesplitst in een private _*_synthesize(chunk, api_key) -> bytes helper
  • Publieke tts_* functie chunkt, logt (Chunk X/Y (N chars)...) en voegt samen
  • PROVIDERS dict en CLI interface zijn ongewijzigd

Openstaande taken

  • Gitea Actions workflow aanmaken (.gitea/workflows/tts.yml)
  • Nginx configuratie voor audio.hiddenden.cafe
  • Astro <audio> player integreren in blogpost layout (src/pages/blog/[...slug].astro)
## ✅ Implementatie — `scripts/tts_generate.py` bijgewerkt De volgende fixes zijn doorgevoerd via Claude Code op basis van twee gevonden problemen: env vars werden niet geladen en lange blogposts produceerden slechte audio. --- ### `.env` loader (`load_dotenv`, regel 22–33) - Zoekt `.env` twee niveaus boven het script (= project root) - Parset `KEY=VALUE`, skipt comments en lege regels, stript quotes - Gebruikt `setdefault` — shell env vars worden nooit overschreven ### Chunking (`split_into_chunks`, regel 79–103) - Splitst eerst op alinea's (`\n\n`), dan op zinnen (`(?<=[.!?])\s+`) als een alinea te lang is - Voegt segmenten samen zolang ze onder de 4000 chars blijven - Gooit nooit tekst weg — volledige blogpost wordt altijd verwerkt ### MP3 merge (`merge_audio_chunks`, regel 106–120) - Puur byte-concatenatie — MP3 frames zijn zelfstandig, werkt voor alle players - Stopt 16 × 417-byte silent frames tussen chunks (≈418ms pauze) - Silent frame: `\xff\xfb\x90\x00` + 413 null bytes (geldig 128kbps/44100Hz frame) - Geen externe packages — puur stdlib ### Provider functies - Elk gesplitst in een private `_*_synthesize(chunk, api_key) -> bytes` helper - Publieke `tts_*` functie chunkt, logt (`Chunk X/Y (N chars)...`) en voegt samen - `PROVIDERS` dict en CLI interface zijn ongewijzigd --- ### Openstaande taken - [ ] Gitea Actions workflow aanmaken (`.gitea/workflows/tts.yml`) - [ ] Nginx configuratie voor `audio.hiddenden.cafe` - [ ] Astro `<audio>` player integreren in blogpost layout (`src/pages/blog/[...slug].astro`)
Latte closed this issue 2026-04-09 18:51:10 +00:00
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Hiddenden/Cozy-Den#72