wjs-transcribing-audio
Audio parlé → SRT horodaté dans la même langue. Cette compétence s'arrête au SRT dans la langue source. La traduction vers une autre langue est la compétence suivante (/wjs-translating-subtitles).
Quand l'utiliser
- L'utilisateur fournit un fichier vidéo ou audio et souhaite une transcription / un SRT dans la langue source.
- L'utilisateur possède déjà un SRT traduit mais le SRT source est manquant.
- L'utilisateur demande "faire un SRT" / "créer des sous-titres" / "générer une transcription mot à mot" sans encore demander de traduction.
Quand NE PAS l'utiliser
- Le SRT dans la langue source existe déjà → passer directement à
/wjs-translating-subtitles. - L'utilisateur souhaite la transcription dans une langue différente de celle parlée → exécuter d'abord cette compétence, puis
/wjs-translating-subtitles. - L'utilisateur ne souhaite que le doublage ou le burn-in → si le SRT existe, passer ; sinon, exécuter cette compétence d'abord.
Routage : quel moteur
| Langue source | Moteur par défaut | Pourquoi |
|---|---|---|
| Chinois (zh-CN, zh-HK, zh-TW) | Volcano (豆包) ASR | Précision matériellement supérieure à Whisper pour le chinois — préférence constante de l'utilisateur |
| Toute autre (es, en, pt, fr, it, ja, ko, …) | API OpenAI Whisper avec granularité mot | Le multilinguisme de Whisper est solide ; les timestamps des mots nous permettent d'assembler nous-mêmes les répliques |
| Hors ligne / pas d'accès API | openai-whisper local (medium) | Qualité minimale ; les mêmes modes de défaillance par blocs/boucles s'appliquent |
Pour le chinois, ne pas utiliser Whisper par défaut, sauf si l'utilisateur le demande explicitement ou si Volcano est indisponible. Il s'agit d'une décision de routage délibérée — voir la mémoire de l'utilisateur sur la priorité de l'ASR chinois.
Chemin API OpenAI Whisper (non-chinois, et chinois de repli)
Le principe clé : ne pas demander response_format=srt. La segmentation des répliques de Whisper échoue sur les longs monologues (répliques en blocs de 30 secondes) et les passages calmes (hallucinations en boucle). Demandez des timestamps au niveau du mot et assemblez vous-même les répliques — le post-traitement est déterministe et gratuit.
Pourquoi pas response_format=srt
Deux modes de défaillance qui ruinent la sortie SRT de whisper-1 sur du contenu long :
- Répliques blobs de 30 secondes. Dans les longs monologues,
whisper-1avecresponse_format=srtémet une réplique couvrant toute la fenêtrecondition_on_previous_textde 30s. La transcription est correcte ; le timing est inutilisable pour une lecture à l'écran. - Hallucination en boucle sur les fins silencieuses.
temperature=0gourmand sur un audio de faible énergie produit "你如果不把拥抱浪费写在这上面,你很难的" répété 50 fois.
Les deux proviennent du fait de laisser Whisper décider des limites des répliques. Solution : timestamps au niveau du mot + votre propre assembleur sensible à la ponctuation.
Appel de l'API
# 1. Compression pour le téléchargement — du MP3 mono à 64kbps est amplement suffisant pour la parole.
# La limite d'OpenAI est de 25 Mo par requête ; découper en morceaux de 10 minutes
# (≈4,5 Mo à 64kbps) pour la résilience sous des proxies instables.
ffmpeg -hide_banner -loglevel error -y \
-ss <début> -t 600 -i entree.mp4 \
-vn -ac 1 -ar 16000 -c:a libmp3lame -b:a 64k chunk.mp3
# 2. Demander des timestamps au niveau du mot. NE PAS demander response_format=srt.
import httpx, os
donnees = {
"model": "whisper-1",
"language": "es", # verrouiller la langue source ; ne jamais détecter automatiquement
"response_format": "verbose_json",
"timestamp_granularities[]": "word", # ← le drapeau critique
"temperature": "0.2", # activer la chaîne de repli (anti-boucle)
}
with open("chunk.mp3", "rb") as f:
r = httpx.post(
"https://api.openai.com/v1/audio/transcriptions",
headers={"Authorization": f"Bearer {os.environ['OPENAI_API_KEY']}"},
data=donnees,
files={"file": ("chunk.mp3", f, "audio/mpeg")},
timeout=600.0,
)
r.raise_for_status()
j = r.json()
mots = j["words"] # [{"word": "hola", "start": 0.12, "end": 0.34}, ...]
segments = j["segments"] # voir la surprise ci-dessous
Surprise : words[] n'a pas de ponctuation, segments[] est incohérent
Le tableau words[] de Whisper n'a généralement pas de ponctuation dans word["word"] — chaque entrée est un jeton nu comme "做", "个", "测", "试". La ponctuation, lorsqu'elle est présente, ne réside que dans le champ text de segments[].
Pire, le texte de segments[] est ponctué de manière incohérente d'un morceau à l'autre du même fichier : le morceau 0 d'un podcast de 79 min peut émettre 285 segments nus ("做个测试" "你在" "呵呵") de 1 à 2 secondes chacun sans ponctuation ; le morceau 7 peut émettre 34 segments de 14 à 30 secondes chacun avec ponctuation. Les deux comportements sont livrés dans la même réponse API.
Donc la bonne recette combine les deux : utiliser segments[] pour les limites de pause naturelles (déjà alignées sur la respiration), mais les traiter comme une entrée brute pour votre propre assembleur de répliques, qui utilise les timestamps des mots pour diviser partout où les segments sont trop longs.
Recette d'assemblage des répliques
DUREE_CIBLE = 3.0 # essayer de faire des répliques de cette durée
DUREE_MAX_REPLIQUE = 5.0 # ne jamais dépasser
MAX_CARACTERES = 18 # ~une ligne à TaillePolice 14 sur 1080 largeur verticale
GAP_MAX = 1.0 # seuil de silence → forcer une limite de réplique
PIECE_MIN = 0.3 # en dessous de cela, fusionner avec le voisin
PONCT_SEP = set(",。!?;,.;!?")
# Étape A : fusionner les segments[] courts vers DUREE_CIBLE (utiliser segments,
# pas mots — les limites de segment de Whisper sont déjà
# alignées sur les pauses).
def assembler(segments, decalage):
repliques, buf = [], []
def vider():
if buf:
repliques.append((buf[0]["start"]+decalage, buf[-1]["end"]+decalage,
"".join(s["text"].strip() pour s dans buf)))
buf.clear()
pour s dans segments:
duree = s["end"] - s["start"]
# Segment long unique AVEC ponctuation interne → diviser en autonome
si duree > DUREE_MAX_REPLIQUE et tout(c dans s["text"] pour c dans PONCT_SEP):
vider(); repliques.extend(diviser_segment_long(s, decalage)); continuer
si pas buf: buf.append(s); continuer
si (s["start"] - buf[-1]["end"]) >= GAP_MAX \
ou (buf[-1]["end"] - buf[0]["start"]) >= DUREE_CIBLE \
ou (s["end"] - buf[0]["start"]) > DUREE_MAX_REPLIQUE:
vider()
buf.append(s)
vider(); retourner repliques
# Étape B : passage final — diviser chaque virgule/point interne en sa propre réplique
# (horodatages proportionnels par position de caractère). Fusionner les morceaux
# plus courts que PIECE_MIN vers l'avant.
# Étape C : toute réplique encore > MAX_CARACTERES est divisée au plus grand écart
# inter-mot en utilisant les timestamps de mots[]. Récursif jusqu'à rester sous le plafond.
Ajustez DUREE_CIBLE et MAX_CARACTERES au rythme de lecture de la plateforme. La limite de 18 caractères est importante pour le burn-in sur du 1080×1920 vertical à TaillePolice=14 — plus long, ça passe à plusieurs lignes illisibles.
Détails opérationnels
- Authentification : les identifiants se trouvent dans
~/code/.env. Charger avecset -a; source ~/code/.env; set +aavant d'invoquer. - Proxy SOCKS sur cette machine :
httpxa besoin de l'extensionsocksio— utiliseruvx --with httpx --with socksio python ...(sans cela, vous obtenezImportError: Using SOCKS proxy, but the 'socksio' package is not installed). - Découpage : des morceaux de 10 minutes en MP3 mono 64kbps (~4,5 Mo chacun) sont le point idéal de fiabilité. Des morceaux de 20 minutes (~9 Mo) provoquent parfois une RST sous des proxies instables. La concurrence
max_workers=2est plus fiable que4. - Réessai : chaque appel API est enveloppé dans un backoff exponentiel ×5 (
time.sleep(min(2**n, 30))) —RemoteProtocolError: Server disconnectedest courant et transitoire. - Assemblage des décalages : les mots de chaque morceau reviennent avec des horodatages relatifs à ce morceau. Lors de la fusion, ajouter le décalage de début absolu du morceau aux
start/endde chaque mot avant d'assembler les répliques. - Garde-fou de boucle (ceinture et bretelles) : même avec
temperature=0.2, un sous-morceau boucle parfois. Après l'assemblage, exécuter un détecteur de boucle sur le texte de chaque réplique — si une phrase de longueur 8-40 caractères se répète 3 fois ou plus consécutivement, supprimer la réplique.
Anti-patrons (à ne pas faire)
- ❌ Ne demandez pas
response_format=srtpour du contenu de plus de ~2 minutes. - ❌ N'essayez pas de "corriger" les mauvaises répliques avec un deuxième appel API. Si vous avez obtenu des répliques blob ou des hallucinations en boucle de votre premier appel, refaites-le une fois avec la granularité au niveau du mot — ne retranscrivez pas seulement la sous-plage cassée.
- ❌ N'utilisez pas
temperature=0sur un audio potentiellement silencieux (yoga, contenu spirituel, fins de podcast). Le décodage gourmand boucle.0.2active la chaîne de repli. - ❌ N'omettez pas
language=.... La détection automatique échange occasionnellement le chinois→japonais ou l'espagnol→portugais sur les 30 premières secondes et toute la transcription est alors fausse.
Chemin Volcano (豆包) ASR — préféré pour le chinois
L'ASR Volcano surpasse régulièrement Whisper en précision du mandarin (taux de reconnaissance, ponctuation, entités nommées). Utilisez-le par défaut pour les sources zh-*.
⛔ NE JAMAIS utiliser 飞书妙记 / lark-minutes pour l'ASR. Il ne donne que des horodatages au niveau des tours de parole, pas un timing mot par mot — les sous-titres construits à partir de cela dérivent de plusieurs secondes et cassent à des endroits non naturels. La règle constante de l'utilisateur : "以后不要用飞书妙记来做ASR,不能作为SRT使用,记住". Il n'y a pas de repli 飞书妙记. Si Volcano est indisponible, utiliser le chemin OpenAI Whisper au niveau du mot ci-dessus (verrouiller
language=zh).
Utiliser l'API WebSocket STREAMING — envoie des octets, n'a besoin d'AUCUNE URL publique
Les API Fichier / 录音文件识别 / MediaKit nécessitent toutes une URL audio HTTP(S) publiquement accessible. L'utilisateur a rejeté l'hébergement d'URL ("不要再用什么从服务器端去 download 的这 mp3 这样的模式" — les tunnels échouent sur leur point d'accès). L'API 大模型流式语音识别 (streaming grand modèle) contourne complètement l'URL en poussant des octets PCM bruts sur un WebSocket. C'est le chemin qui fonctionne. Elle renvoie des horodatages mot par mot en ms.
Scripts fournis (utilisez-les — ce sont les chemins de travail vérifiés) :
# 1. Transcrire : envoie des octets PCM mono 16k sur WebSocket → JSON ASR
# (décode toute entrée via ffmpeg ; .pcm passe directement)
export VOLC_ASR_APPID=… VOLC_ASR_ACCESS_TOKEN=… # les identifiants vivent avec l'utilisateur
python3 scripts/volc_asr_stream.py <clip.mp4|wav|mp3|pcm> <out.asr.json>
# 2. Construire un SRT propre et horodaté à partir du JSON ASR
python3 scripts/build_srt_from_asr.py <out.asr.json> <out.srt> [max_chars=18]
# Paramètres de segmentation (optionnels) :
# --max-chars N augmenter pour garder les répliques entières / couper à la ponctuation, pas au milieu d'une phrase
# --soft-min N nb min de caractères avant qu'une ,、; ne vide une réplique (défaut 8)
# --strip-punct supprimer TOUTE la ponctuation du texte affiché (rendu de sous-titre propre)
- Point d'accès :
wss://openspeech.bytedance.com/api/v3/sauc/bigmodel - En-têtes :
X-Api-App-KEY:{appid},X-Api-Access-Key:{token},X-Api-Resource-Id:volc.bigasr.sauc.duration,X-Api-Connect-Id:{uuid} - Framing binaire v3 : en-tête 4 octets
[(ver<<4)|hdrsize, (msgtype<<4)|flags, (ser<<4)|comp, 0]. Full-client(0x1)+POS_SEQ envoie la config JSON compressée gzip ; les paquets audio(0x2) envoient des morceaux PCM compressés gzip (200ms = 6400 octets @16k mono s16le) ; le dernier paquet utilise NEG_WITH_SEQ(0x3) avec une séquence négative. La réponse full-response(0x9) du serveur renvoie du JSON compressé gzip. - JSON de configuration :
{user:{uid}, audio:{format:"pcm",rate:16000,bits:16,channel:1,codec:"raw"}, request:{model_name:"bigmodel", enable_punc:true, enable_itn:true, show_utterances:true}}. NE PAS définirresult_type:"single"— cela ne renvoie que la dernière phrase à chaque trame ; le mode par défaut accumule toutes les utterances. - Forme du résultat :
result.utterances[], chacune avectext(ponctué) +words[](jeton +start_time/end_timeen ms). Les jetons latins comme "AI" reviennent avecstart=end=0—build_srt_from_asr.pyles remplit en avant/arrière à partir des voisins. build_srt_from_asr.pyfait aussi : diviser les répliques sur la ponctuation (HARD。!?vide ; SOFT,、;vide après--soft-mincaractères ; plafond dur--max-chars), supprimer optionnellement toute ponctuation de l'affichage (--strip-punct), supprimer les remplisseurs 呃/嗯/唉, et réduire les jetons courts immédiatement en double (才才→才). Chaque réplique est chronométrée par son premier/dernier mot pour qu'elle repose exactement sur l'audio parlé — pas de dérive. Pour éviter les coupures en milieu de phrase, augmentez--max-charspour que les limites tombent sur la ponctuation plutôt que sur le plafond de caractères.- Transcription par clip : transcrire l'audio de chaque clip lui-même (PCM mono 16k) pour que les horodatages SRT soient relatifs au clip.
- Identifiants : vivent avec l'utilisateur (moteur vocal 豆包). Noms d'env acceptés par le script :
VOLC_ASR_APPID/VOLC_APPIDetVOLC_ASR_ACCESS_TOKEN/VOLC_TOKEN;FFMPEG_BINoptionnel.- L'ASR 火山 et la TTS de cet utilisateur PARTAGENT UN SEUL jeu d'identifiants — le même appid + access token fonctionnent pour les deux services. Ils sont stockés dans
~/code/.envsousVOLC_TTS_APPID/VOLC_TTS_ACCESS_TOKEN(plusVOLC_APPIDdans~/.zshrc), avec les aliasVOLC_ASR_APPID/VOLC_ASR_ACCESS_TOKENajoutés pointant vers les mêmes valeurs. Doncset -a; source ~/code/.env; set +asuffit — pas de jeton ASR séparé à chercher. Si les alias sont un jour manquants, recréez-les à partir des valeurs TTS (c'est le même secret).
- L'ASR 火山 et la TTS de cet utilisateur PARTAGENT UN SEUL jeu d'identifiants — le même appid + access token fonctionnent pour les deux services. Ils sont stockés dans
- Impasses (ne pas réessayer) : MediaKit asr-subtitles (doc 6448/2381968) nécessite encore une URL publique et une clé API MediaKit séparée. L'API fichier 录音文件识别 a besoin d'une URL publique.
Whisper local en dernier recours
Uniquement hors ligne, lorsque le quota API est épuisé, ou pour des brouillons ultra-bon marché. La qualité est matériellement inférieure pour le chinois ; les mêmes modes de défaillance blob/boucle s'appliquent ; le Whisper local n'expose pas les horodatages au niveau du mot via la CLI, donc la correction de principe n'est pas disponible.
ffmpeg -i entree.mp4 -vn -ac 1 -ar 16000 -c:a pcm_s16le _audio.wav -y
uvx --from openai-whisper whisper _audio.wav \
--language zh --task transcribe \
--model medium --output_format srt --output_dir .
rm _audio.wav
medium est le plancher pratique pour la précision du chinois ; small n'est acceptable que pour un anglais studio propre. Whisper écrit . millisecondes ; le fichier est toujours un SRT valide. Si vous régénérez le SRT, émettez toujours , ms.
Passage de raffinement IA — corriger les fautes de frappe évidentes (étape finale, toujours exécuter pour le chinois)
Même l'ASR Volcano produit des erreurs homophoniques claires qui se lisent mal à l'écran — observées : 「总数」→「意数」, 「需求」→「虚求」, 「程序员」→「成员」. Le texte ASR brut est le plancher, pas le plafond. Après l'assemblage du SRT, faites un passage de raffinement Claude sur l'ensemble du SRT pour corriger les erreurs évidentes en utilisant le contexte de la phrase.
Ce passage est effectué en session par Claude lisant le SRT — aucun appel API externe. Réécrivez le .srt sur place (ou en <stem>.polished.srt).
Règles strictes — ceci est une correction, pas une édition :
- ✅ Corrigez uniquement les erreurs claires d'homophones / fautes de frappe où le mot prévu est sans ambiguïté d'après le contexte (意数→总数, 虚求→需求).
- ⛔ NE JAMAIS modifier les horodatages, les répliques