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,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