Digest OSS Hacker News: manuale completo + workflow n8n + script audio (riproducibile, MIT)
This commit is contained in:
commit
3bf557f92d
4 files changed
+1424
No files matched your search
@@ -0,0 +1,198 @@
|
|||||||
|
# 🦊 Digest OSS Hacker News — vocalino e testo, ogni 12 ore
|
||||||
|
|
||||||
|
Un sistema **completamente gratuito e riproducibile** che:
|
||||||
|
|
||||||
|
1. raccoglie **tutti** i progetti open source apparsi su Hacker News nelle ultime 12 ore
|
||||||
|
2. **legge il README vero** di ogni progetto prima di descriverlo (niente descrizioni dal titolo!)
|
||||||
|
3. scrive un digest testuale **in italiano** e lo invia su un canale/bot Telegram
|
||||||
|
4. genera un **vocalino "raccontato"** (stile podcast, non elenco) con TUTTI i progetti, uno per uno, e lo consegna come messaggio vocale
|
||||||
|
|
||||||
|
Dove gira: **n8n** (testo + checkpoint) e **Hermes Agent** (audio) su un VPS con Docker.
|
||||||
|
Nessun servizio a pagamento: le uniche chiavi necessarie sono gratuite (Google AI Studio, un bot Telegram, API n8n).
|
||||||
|
|
||||||
|
> **Perché "tutti i progetti"?** Perché il filtro è: Show HN + qualsiasi storia con link a GitHub/GitLab/Codeberg + news open source con ≥3 punti. Non si scelgono i "migliori": si perdono zero news, e la selezione la fai tu mentre ascolti.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architettura in un disegno
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐ ogni 12h ┌──────────────────────────────────────────┐
|
||||||
|
│ Hacker News │────────────▶│ n8n workflow "Digest OSS Hacker News" │
|
||||||
|
│ (Algolia API)│ │ 1. Legge il checkpoint (data table) │
|
||||||
|
└──────────────┘ │ 2. Scarica Show HN + storie dal last run │
|
||||||
|
│ 3. Filtra OSS (repo link, keyword) │
|
||||||
|
│ 4. SCARICA IL README di ogni progetto │
|
||||||
|
│ 5. Gemini Flash: digest italiano │
|
||||||
|
│ (descrizioni basate sul README) │
|
||||||
|
│ 6. Invia su Telegram (bot) │
|
||||||
|
│ 7. Checkpoint avanti (append-only) │
|
||||||
|
└──────────────┬───────────────────────────┘
|
||||||
|
│ 20 min dopo
|
||||||
|
▼
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Hermes cron "Digest OSS audio raccontato"│
|
||||||
|
│ 1. Prende il digest dall'esecuzione n8n │
|
||||||
|
│ 2. Scrive la NARRAZIONE (stile podcast) │
|
||||||
|
│ 3. Gemini TTS → MP3 (segmenti + cache) │
|
||||||
|
│ 4. Consegna il vocalino in chat │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Principi chiave** (quelli che rendono il sistema affidabile):
|
||||||
|
|
||||||
|
- **Checkpoint anti-buchi**: un timestamp dell'ultimo momento elaborato vive in una data table di n8n in modalità *append-only*. Se tutto si rompe (VPS giù, API sovraccarica), il checkpoint non avanza e alla ripresa il digest riprende da dove era. Zero news perse, verificato in produzione.
|
||||||
|
- **Verifica README**: ogni descrizione è basata sul testo reale del repository. Se il README non è raggiungibile, la voce è marcata `[non verificato]`.
|
||||||
|
- **Ridondanza Gemini**: il modello principale sovraccarico? La chiamata passa automaticamente a `flash-lite`. Quota TTS esaurita? Il generatore audio fa cascata tra 4 modelli TTS.
|
||||||
|
- **Cache dei segmenti audio**: ogni pezzo di vocalino generato viene salvato su disco. Riavvii e crash non fanno rifare nulla.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisiti
|
||||||
|
|
||||||
|
| Cosa | Dove | Costo |
|
||||||
|
|---|---|---|
|
||||||
|
| Server con Docker + n8n | il tuo VPS | già tuo |
|
||||||
|
| API key Gemini | [aistudio.google.com](https://aistudio.google.com) → "Get API key" | gratis (1.500 req/giorno sui modelli Flash) |
|
||||||
|
| Bot Telegram | [@BotFather](https://t.me/BotFather) → `/newbot` | gratis |
|
||||||
|
| Chat ID tuo | scrivi al tuo bot e leggi `getUpdates` (vedi sotto) | gratis |
|
||||||
|
| API key n8n | n8n → Settings → n8n API → crea chiave | gratis |
|
||||||
|
| Hermes Agent | [hermes-agent](https://github.com/NousResearch/hermes-agent) sullo stesso VPS | open source |
|
||||||
|
|
||||||
|
Per scoprire il tuo chat ID Telegram: scrivi un messaggio al tuo bot, poi
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl "https://api.telegram.org/bot<TOKEN>/getUpdates" | grep -o '"id":[0-9]*' | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installazione (30 minuti circa)
|
||||||
|
|
||||||
|
### 1. La data table del checkpoint in n8n
|
||||||
|
|
||||||
|
Con la tua API key n8n:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://TUO-N8N/api/v1/data-tables \
|
||||||
|
-H "X-N8N-API-KEY: CHIAVE" -H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "hn_checkpoint", "columns": [{"name": "sinceEpoch", "type": "number"}]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Segna l'`id` dalla risposta. Poi inserisci la prima riga (parti da "12 ore fa"):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SINCE=$(( $(date +%s) - 43200 ))
|
||||||
|
curl -X POST https://TUO-N8N/api/v1/data-tables/<TABLE_ID>/rows \
|
||||||
|
-H "X-N8N-API-KEY: CHIAVE" -H "Content-Type: application/json" \
|
||||||
|
-d "{\"data\": [{\"sinceEpoch\": $SINCE}]}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Il workflow
|
||||||
|
|
||||||
|
1. In n8n: **Import from File** → `n8n/workflow-digest-oss.json`
|
||||||
|
2. Apri il nodo **Credenziali** (Set) e compila:
|
||||||
|
- `gemini_key` — la tua chiave Google AI Studio
|
||||||
|
- `telegram_token` — token del bot
|
||||||
|
- `telegram_chat_id` — il tuo chat ID
|
||||||
|
- `n8n_key` — la stessa chiave API n8n (il workflow legge la sua data table via REST API; il nodo DataTable nativo è rotto su n8n con task runner — vedi Pitfall #5)
|
||||||
|
3. Apri i nodi **Leggi checkpoint** e **Aggiorna checkpoint** e sostituisci l'ID `IL_TUO_TABLE_ID` con il TABLE_ID del punto 1
|
||||||
|
4. Attiva il workflow. Per il test: metti un cron one-shot a ~10 minuti nel futuro (in ora **locale** del tuo n8n — controlla il fuso!) e attendi l'esecuzione
|
||||||
|
|
||||||
|
**Test riuscito quando**: esecuzione verde in n8n → messaggi sul bot Telegram → nuova riga nella data table.
|
||||||
|
|
||||||
|
### 3. Le chiavi per la parte audio (Hermes)
|
||||||
|
|
||||||
|
Crea `hn-oss-digest/creds.json` (fuori dal git!) nel file system di Hermes, e metti lì anche gli ID:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"gemini_key": "AIza...",
|
||||||
|
"telegram_token": "12345:ABC...",
|
||||||
|
"n8n_key": "eyJhbG...",
|
||||||
|
"telegram_chat_id": "123456789",
|
||||||
|
"n8n_base": "https://TUO-N8N",
|
||||||
|
"n8n_table_id": "ID_TABELLA",
|
||||||
|
"n8n_workflow_id": "ID_DEL_WORKFLOW_IMPORTATO"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Poi in `scripts/fetch_latest_digest.py` sostituisci `https://n8n.as89.it` con il tuo dominio n8n e l'ID workflow con il tuo.
|
||||||
|
|
||||||
|
### 4. Gli script audio
|
||||||
|
|
||||||
|
Copia `scripts/` nel file system di Hermes. Serve `ffmpeg` sul sistema (`apt install ffmpeg`).
|
||||||
|
|
||||||
|
Prova del motore TTS:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd <file-system-hermes>/hn-oss-digest
|
||||||
|
printf 'Test della voce. Funziona!\n' > /tmp/t.md
|
||||||
|
python3 narrate_to_audio.py /tmp/t.md /tmp/t.mp3
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Il cron Hermes (il vocalino)
|
||||||
|
|
||||||
|
Nella chat con Hermes, chiedi di creare un cron job con queste istruzioni (regolane i dettagli):
|
||||||
|
|
||||||
|
> Ogni giorno alle 20:20 ora italiana:
|
||||||
|
> 1. esegui `fetch_latest_digest.py`; se l'executionId è già in `narrated-ids.txt`, fermati
|
||||||
|
> 2. leggi `latest-digest.json` e scrivi `narration.md`: TUTTI i progetti, uno per uno, stile podcast (mai elenco meccanico), ~1 minuto per progetto, chiudendo OGNI progetto con "il nome da cercare è: <nome>"
|
||||||
|
> 3. esegui `narrate_to_audio.py` → `digest-audio.mp3`
|
||||||
|
> 4. consegna in chat il file come messaggio vocale
|
||||||
|
|
||||||
|
⚠️ **Trappola del fuso**: lo scheduler di Hermes interpreta gli orari naturali come **UTC**. Per le 20:20 italiane il cron è `20 18 * * *`. Verifica SEMPRE il campo `next_run_at` dopo la creazione.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## I cinque pitfall reali (ci hanno costo ore — a te costeranno minuti)
|
||||||
|
|
||||||
|
### 1. Il Code node di n8n (task runner) è una sandbox povera
|
||||||
|
- ❌ niente `fetch`, niente `URL`, niente `AbortSignal`, niente `process`
|
||||||
|
- ❌ `require('https')` → "Module disallowed"
|
||||||
|
- ✅ `this.helpers.httpRequest({url, timeout})` — **l'unico modo** di chiamare HTTP da un Code node
|
||||||
|
- Parse degli URL a **regex** (vedi `readmeUrlFor` nel workflow), non con `new URL()`
|
||||||
|
|
||||||
|
### 2. Il codice del Code node è CACHE per workflow ID
|
||||||
|
Modificare `jsCode` via API (o UI!) non aggiorna l'esecuzione: il task runner tiene il codice della **prima compilazione** per quel workflow ID. Il trigger nuovo parte, ma esegue il codice vecchio — silenziosamente.
|
||||||
|
**Fix**: a ogni cambio di logica crea un workflow NUOVO (ID nuovo) e ritira il vecchio. Per debuggare il sandbox: workflow TMP monouso con `jsCode` di sonda (`typeof fetch`, lista globals) e leggi il runData dell'esecuzione.
|
||||||
|
|
||||||
|
### 3. I fail silenziosi sono il nemico
|
||||||
|
Il primo fetch README ingoiava gli errori con `catch {}` e *sembrava* funzionare: 0 download su 50 senza un messaggio. **Regola**: ogni operazione fallibile registra il proprio errore per item (`readmeErr = 'HTTP ' + r.status`), e leggi le esecuzioni con `?includeData=true`.
|
||||||
|
|
||||||
|
### 4. Fusi orari: tre livelli di trappola
|
||||||
|
- n8n Schedule Trigger usa il timezone delle **settings del workflow** (mettilo su Europe/Rome)
|
||||||
|
- Hermes cron interpreta gli orari naturali come **UTC**
|
||||||
|
- I cron one-shot di test si impostano SOLO dopo aver guardato `date`: un "test alle 10:35" salvato alle 11:15 non scatta mai
|
||||||
|
|
||||||
|
### 5. Il nodo DataTable nativo di n8n può essere rotto
|
||||||
|
Con i task runner abilitati, il nodo DataTable crasha (`Cannot read properties of undefined (reading 'execute')`) a prescindere dai parametri. Il workflow usa invece **due HTTP Request** che chiamano la REST API di n8n stessa (`GET/POST /data-tables/<id>/rows`) con l'header `X-N8N-API-KEY` presa dal nodo Credenziali. Funziona da sempre e dà anche lo storico (append-only).
|
||||||
|
Nota: l'API pubblica delle data tables supporta solo GET e POST righe (corpo `{"data":[{...}]}`) — niente update/delete per riga: ecco perché il checkpoint è append-only (leggi il MAX, non l'ultima).
|
||||||
|
|
||||||
|
Bonus — **quota TTS Gemini**: il free tier è ~10 richieste/giorno **per modello TTS** (il 429 dura fino a ~02:00 ora italiana). `narrate_to_audio.py` fa cascata su 4 modelli TTS (`MODELS`) e cache dei segmenti: il retry costa zero.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File
|
||||||
|
|
||||||
|
```
|
||||||
|
n8n/workflow-digest-oss.json workflow n8n completo (chiavi rimosse — reinseriscile)
|
||||||
|
scripts/fetch_latest_digest.py prende l'ultimo digest riuscito dall'API n8n → latest-digest.json
|
||||||
|
scripts/narrate_to_audio.py narrazione → MP3 (segmentazione, cache, cascata TTS, anti-divagazione)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Personalizzazioni rapide
|
||||||
|
|
||||||
|
| Vuoi... | Tocca... |
|
||||||
|
|---|---|
|
||||||
|
| descrizioni più lunghe/corte | nodo `Seleziona OSS` → prompt ("4 a 6 frasi", "400 caratteri") |
|
||||||
|
| voce più lenta/rapida | `STYLE` in `narrate_to_audio.py` (es. aggiungi "speak slowly") |
|
||||||
|
| altra voce | `voiceName` nel TTS (Kore, Charon, Orus, Sulafat, Puck…) — fatti un provino! |
|
||||||
|
| più/meno progetti | filtri nel Code `Seleziona OSS` (soglia punti, keyword OSS) |
|
||||||
|
| orari | nodo `Ogni 12 ore` in n8n; cron del job Hermes |
|
||||||
|
|
||||||
|
## Licenza e note
|
||||||
|
|
||||||
|
- Codice e manuale: MIT — prendi, adatta, condividi.
|
||||||
|
- Le API gratuite cambiano limiti spesso: i numeri di questo README erano veri a ottobre 2026.
|
||||||
|
- Se il progetto ti è utile, il ringraziamento giusto è un contributo a [Hermes Agent](https://github.com/NousResearch/hermes-agent) o a [n8n](https://github.com/n8n-io/n8n) — su cui tutto questo è costruito.
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scarica il JSON dell'ultimo digest RIUSCITO del workflow v3 e lo salva in latest-digest.json.
|
||||||
|
Chiamato dal cronjob prima della narrazione. Include il testo dei README per la narrazione esaustiva."""
|
||||||
|
import json, sys, urllib.request, urllib.error
|
||||||
|
|
||||||
|
C = json.load(open('/opt/data/hn-oss-digest/creds.json'))
|
||||||
|
H = {'X-N8N-API-KEY': C['n8n_key']}
|
||||||
|
BASE = 'https://n8n.as89.it/api/v1'
|
||||||
|
WID = 'tZwGIY9rcdybQLPr'
|
||||||
|
|
||||||
|
def http(url):
|
||||||
|
req = urllib.request.Request(url, headers=H)
|
||||||
|
with urllib.request.urlopen(req, timeout=60) as r:
|
||||||
|
return r.read().decode()
|
||||||
|
|
||||||
|
d = json.loads(http(f'{BASE}/executions?limit=10&status=success&workflowId={WID}'))
|
||||||
|
data = d.get('data', [])
|
||||||
|
if not data:
|
||||||
|
print('NESSUNA ESECUZIONE RIUSCITA TROVATA')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
eid = data[0]['id']
|
||||||
|
x = json.loads(http(f'{BASE}/executions/{eid}?includeData=true'))
|
||||||
|
rd = x['data']['resultData']['runData']
|
||||||
|
sel = rd['Seleziona OSS'][0]['data']['main'][0][0]['json']
|
||||||
|
|
||||||
|
def node_json(node):
|
||||||
|
"""Primo json di output non vuoto del nodo, o None."""
|
||||||
|
runs = rd.get(node)
|
||||||
|
if not runs:
|
||||||
|
return None
|
||||||
|
for conn in runs[0].get('data', {}).get('main', []):
|
||||||
|
if conn:
|
||||||
|
return conn[0].get('json')
|
||||||
|
return None
|
||||||
|
|
||||||
|
g = node_json('Gemini riassume')
|
||||||
|
src = 'Gemini riassume'
|
||||||
|
if not g or 'candidates' not in g:
|
||||||
|
g = node_json('Gemini lite (fallback)')
|
||||||
|
src = 'Gemini lite (fallback)'
|
||||||
|
if not g or 'candidates' not in g:
|
||||||
|
print('NESSUN RIASSUNTO DISPONIBILE')
|
||||||
|
sys.exit(1)
|
||||||
|
print(f'sorgente riassunto: {src}')
|
||||||
|
txt = g['candidates'][0]['content']['parts'][0]['text']
|
||||||
|
dj = json.loads(txt)
|
||||||
|
|
||||||
|
# item originali (con il testo dei README) allegati alle sezioni finali
|
||||||
|
readme_by_url = {it.get('url', ''): it.get('readme', '') for it in sel.get('items', [])}
|
||||||
|
for k in ('show', 'repo', 'news'):
|
||||||
|
for it in dj.get('sections', {}).get(k, []):
|
||||||
|
it['readme'] = readme_by_url.get(it.get('url', ''), '')
|
||||||
|
|
||||||
|
out = {
|
||||||
|
'executionId': eid,
|
||||||
|
'count': sel.get('count'),
|
||||||
|
'conReadme': sel.get('conReadme'),
|
||||||
|
'sinceOld': sel.get('sinceOld'),
|
||||||
|
'sinceNew': sel.get('sinceNew'),
|
||||||
|
'geminiSource': src,
|
||||||
|
'sections': dj.get('sections', {}),
|
||||||
|
}
|
||||||
|
with open('/opt/data/hn-oss-digest/latest-digest.json', 'w') as f:
|
||||||
|
json.dump(out, f, ensure_ascii=False, indent=1)
|
||||||
|
|
||||||
|
tot = sum(len(out['sections'].get(k, [])) for k in ('show', 'repo', 'news'))
|
||||||
|
conr = sum(1 for k in ('show', 'repo', 'news') for it in out['sections'].get(k, []) if it.get('readme'))
|
||||||
|
print(f'exec {eid}: {tot} item salvati in latest-digest.json | README allegati: {conr}')
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""narration.md -> digest-audio.mp3 — versione resilient:
|
||||||
|
- cache su disco dei segmenti WAV (riparte da dove era: i segmenti gia' fatti non si rifanno)
|
||||||
|
- retry con backoff esponenziale sui 429/503 (fino a 8 tentativi, attesa max ~5 min)
|
||||||
|
- pausa tra le chiamate per stare lontano dal rate limit
|
||||||
|
- guardia anti-divagazione (durata attesa vs reale)
|
||||||
|
Uso: python3 narrate_to_audio.py [narration.md] [output.mp3]"""
|
||||||
|
import base64, hashlib, json, os, re, subprocess, sys, time, urllib.request, urllib.error, wave
|
||||||
|
|
||||||
|
BASEDIR = '/opt/data/hn-oss-digest'
|
||||||
|
C = json.load(open(BASEDIR + '/creds.json'))
|
||||||
|
SRC = sys.argv[1] if len(sys.argv) > 1 else BASEDIR + '/narration.md'
|
||||||
|
OUT = sys.argv[2] if len(sys.argv) > 2 else BASEDIR + '/digest-audio.mp3'
|
||||||
|
CACHE = os.path.join(BASEDIR, 'tts-cache')
|
||||||
|
os.makedirs(CACHE, exist_ok=True)
|
||||||
|
STYLE = ('Speak in Italian with a native Italian accent, warm and passionate like a tech '
|
||||||
|
'podcast narrator, conversational pace, natural pauses between sentences, a hint '
|
||||||
|
'of wonder on curious details, friendly closing energy: \n')
|
||||||
|
PAUSE_BETWEEN = 12 # secondi tra una chiamata TTS e l'altra
|
||||||
|
|
||||||
|
def segments(text, maxlen=1500):
|
||||||
|
paras = [p.strip() for p in re.split(r'\n\s*\n', text) if p.strip()]
|
||||||
|
segs, cur = [], ''
|
||||||
|
for p in paras:
|
||||||
|
if len(cur) + len(p) + 2 <= maxlen:
|
||||||
|
cur = (cur + '\n\n' + p).strip()
|
||||||
|
else:
|
||||||
|
if cur:
|
||||||
|
segs.append(cur)
|
||||||
|
while len(p) > maxlen:
|
||||||
|
cut = p.rfind('. ', 0, maxlen)
|
||||||
|
cut = cut + 1 if cut > 200 else maxlen
|
||||||
|
segs.append(p[:cut].strip())
|
||||||
|
p = p[cut:].strip()
|
||||||
|
cur = p
|
||||||
|
if cur:
|
||||||
|
segs.append(cur)
|
||||||
|
return segs
|
||||||
|
|
||||||
|
MODELS = ['gemini-3.8-flash-lite-tts', 'gemini-3.8-flash-tts',
|
||||||
|
'gemini-3.1-flash-tts-preview', 'gemini-2.5-flash-preview-tts']
|
||||||
|
|
||||||
|
def tts_once(text, model):
|
||||||
|
payload = {
|
||||||
|
'contents': [{'parts': [{'text': STYLE + text}]}],
|
||||||
|
'generationConfig': {
|
||||||
|
'responseModalities': ['AUDIO'],
|
||||||
|
'speechConfig': {'voiceConfig': {'prebuiltVoiceConfig': {'voiceName': 'Kore'}}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
url = ('https://generativelanguage.googleapis.com/v1beta/models/' + model + ':generateContent?key='
|
||||||
|
+ C['gemini_key'])
|
||||||
|
req = urllib.request.Request(url, data=json.dumps(payload).encode(),
|
||||||
|
headers={'Content-Type': 'application/json'})
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as r:
|
||||||
|
x = json.loads(r.read().decode())
|
||||||
|
cand = x['candidates'][0]
|
||||||
|
part = cand['content']['parts'][0]
|
||||||
|
data = base64.b64decode(part['inlineData']['data'])
|
||||||
|
rate = 24000
|
||||||
|
for tok in part['inlineData'].get('mimeType', '').split(';'):
|
||||||
|
if tok.strip().startswith('rate='):
|
||||||
|
rate = int(tok.split('=')[1])
|
||||||
|
if cand.get('finishReason') != 'STOP':
|
||||||
|
raise RuntimeError('finishReason=' + str(cand.get('finishReason')))
|
||||||
|
return data, rate
|
||||||
|
|
||||||
|
def tts(text):
|
||||||
|
"""Cache + cascata di modelli + backoff: se un modello e' a secco (429) prova il prossimo."""
|
||||||
|
key = hashlib.sha256((STYLE + text).encode()).hexdigest()[:16]
|
||||||
|
cache_file = os.path.join(CACHE, key + '.json')
|
||||||
|
if os.path.exists(cache_file):
|
||||||
|
d = json.load(open(cache_file))
|
||||||
|
print(' [cache]', end=' ')
|
||||||
|
return base64.b64decode(d['data']), d['rate']
|
||||||
|
models = [m for m in MODELS]
|
||||||
|
delay = 60
|
||||||
|
for cycle in range(3):
|
||||||
|
for model in list(models):
|
||||||
|
try:
|
||||||
|
data, rate = tts_once(text, model)
|
||||||
|
json.dump({'data': base64.b64encode(data).decode(), 'rate': rate}, open(cache_file, 'w'))
|
||||||
|
return data, rate
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 429:
|
||||||
|
print(f' [{model.split("-tts")[0]}: esaurito]', end=' ', flush=True)
|
||||||
|
continue
|
||||||
|
if e.code in (500, 503):
|
||||||
|
print(f' [{model}: {e.code}, riprovo]', end=' ', flush=True)
|
||||||
|
time.sleep(30)
|
||||||
|
continue
|
||||||
|
raise
|
||||||
|
if cycle < 2:
|
||||||
|
print(f' [tutti a secco, attendo {delay}s]', end=' ', flush=True)
|
||||||
|
time.sleep(delay)
|
||||||
|
delay *= 3
|
||||||
|
raise RuntimeError('tts: tutti i modelli esauriti')
|
||||||
|
|
||||||
|
text = open(SRC, encoding='utf-8').read()
|
||||||
|
segs = segments(text)
|
||||||
|
print(f'narrazione: {len(text)} char -> {len(segs)} segmenti audio')
|
||||||
|
|
||||||
|
frames, rate = [], 24000
|
||||||
|
for i, s in enumerate(segs, 1):
|
||||||
|
data, r = tts(s)
|
||||||
|
dur = len(data) / (r * 2)
|
||||||
|
atteso = max(6.0, len(s) / 14.0)
|
||||||
|
if dur > atteso * 1.8:
|
||||||
|
print(f' segmento {i}: {dur:.0f}s vs attesi ~{atteso:.0f}s -> retry (senza cache)')
|
||||||
|
os.remove(os.path.join(CACHE, hashlib.sha256((STYLE + s).encode()).hexdigest()[:16] + '.json'))
|
||||||
|
data, r = tts(s)
|
||||||
|
dur = len(data) / (r * 2)
|
||||||
|
if dur > atteso * 1.8:
|
||||||
|
print(f' segmento {i}: ancora {dur:.0f}s, lo tengo (possibile divagazione)')
|
||||||
|
rate = r
|
||||||
|
frames.append(data)
|
||||||
|
print(f' segmento {i}/{len(segs)}: {dur:.1f}s')
|
||||||
|
if i < len(segs):
|
||||||
|
time.sleep(PAUSE_BETWEEN)
|
||||||
|
|
||||||
|
wav = OUT.replace('.mp3', '.wav')
|
||||||
|
with wave.open(wav, 'wb') as w:
|
||||||
|
w.setnchannels(1)
|
||||||
|
w.setsampwidth(2)
|
||||||
|
w.setframerate(rate)
|
||||||
|
for f in frames:
|
||||||
|
w.writeframes(f)
|
||||||
|
|
||||||
|
subprocess.run(['ffmpeg', '-y', '-loglevel', 'error', '-i', wav,
|
||||||
|
'-codec:a', 'libmp3lame', '-b:a', '64k', '-ar', '24000', '-ac', '1', OUT],
|
||||||
|
check=True)
|
||||||
|
print('OK:', OUT, os.path.getsize(OUT) // 1024, 'KB')
|
||||||
Reference in new issue
Block a user