Retirer les pauses d'un enregistrement vocal se résume à quatre nombres : un niveau en dessous duquel l'audio est considéré comme silencieux, la durée minimale d'un passage calme avant qu'on le supprime, et la marge à garder de chaque côté des morceaux conservés. Le Sound Editor de l'application de bureau osFoundry expose exactement ces quatre réglages dans sa boîte de dialogue Strip Silence, avec comme valeurs par défaut −48 dB, 50 ms, 5 ms et 20 ms. Ci-dessous, la même opération est reconstruite en une quarantaine de lignes de numpy, vérifiée échantillon par échantillon contre un fichier exporté par l'application, puis utilisée pour mesurer ce que fait vraiment chaque nombre. Pour finir, le même travail avec silenceremove de ffmpeg, qui se comporte différemment sur un point facile à rater.
Les enregistrements de test
Deux prises de test construites pour l'occasion : cinq courtes phrases en synthèse vocale chacune (en anglais et en japonais), séparées par 2,4 à 3,4 secondes de calme, avec un souffle de pièce généré en dessous. 48 kHz, mono, 16 bits.
| prise | durée | RMS du souffle | crête du souffle |
|---|---|---|---|
| Anglais | 27,57 s | −64,6 dBFS | −52,4 dBFS |
| Japonais | 33,55 s | −64,6 dBFS | −52,4 dBFS |
L'écart entre ces deux dernières colonnes compte plus loin. Un souffle dont la moyenne est à −64,6 dBFS a quand même des échantillons isolés qui montent à −52,4.
La règle
Un échantillon est silencieux si sa valeur absolue est inférieure ou égale au seuil. Une suite d'échantillons silencieux peut être supprimée si elle dure au moins la longueur minimale. Tout ce qui se trouve entre deux suites supprimables devient un clip, élargi par les marges de début et de fin :
import numpy as np def silent_runs(x, sr, threshold_db, min_ms): quiet = np.abs(x) <= 10 ** (threshold_db / 20) edges = np.flatnonzero(np.diff(np.concatenate(([0], quiet.view(np.int8), [0])))) runs = edges.reshape(-1, 2) # [start, end) of every quiet run keep = (runs[:, 1] - runs[:, 0]) >= min_ms * sr / 1000 keep |= (runs[:, 0] == 0) | (runs[:, 1] == len(x)) # file edges: always strippable return runs[keep] def strip_silence(x, sr, threshold_db=-48, min_ms=50, pad_start_ms=5, pad_end_ms=20): runs = silent_runs(x, sr, threshold_db, min_ms) bounds = np.concatenate(([0], runs.ravel(), [len(x)])).reshape(-1, 2) ps, pe = int(pad_start_ms * sr / 1000), int(pad_end_ms * sr / 1000) clips = [] for s, e in bounds: if e <= s: continue s, e = max(0, s - ps), min(len(x), e + pe) if clips and s <= clips[-1][1]: # pads made two clips touch: merge clips[-1][1] = e else: clips.append([s, e]) return clips Cette étape ne fait que retirer. Refermer les trous est une étape à part, celle que le Sound Editor appelle Space Clips : mettre les clips bout à bout, en laissant le premier à sa place.
def space_clips(x, clips): return np.concatenate([np.zeros(clips[0][0], x.dtype)] + [x[s:e] for s, e in clips]) Vérification avec l'application
Les deux prises sont passées dans le Sound Editor avec −48 dB, 500 ms, 100 ms et 300 ms, puis Space Clips, puis Bounce Mix normalisé à −0,1 dBFS. Le fichier de l'application est en stéréo 24 bits avec deux canaux identiques. Comparé au résultat numpy, normalisé de la même façon :
| prise | bounce de l'application | numpy | plus grand écart |
|---|---|---|---|
| Anglais | 660 500 échantillons (13,76 s) | 660 500 échantillons | −135,0 dB |
| Japonais | 815 030 échantillons (16,98 s) | 815 030 échantillons | −132,5 dB |
Même longueur à l'échantillon près, et des écarts au niveau de l'arrondi 24 bits. Une simple comparaison échantillon par échantillon, sans fenêtre RMS ni lissage, suffit pour reproduire ce que l'application exporte.
La première version de la reconstruction se trompait sur exactement une zone, et c'est le seul endroit où une implémentation naïve se trompe. Chaque prise commence par 0,47 seconde de calme avant le premier mot, moins que le minimum de 500 ms. Appliquée à la lettre, la règle conserve ce passage, et la sortie commence par 0,47 s de souffle. Le fichier de l'application commence au contraire par 0,376 s de zéro numérique, puis le premier mot : un silence qui touche le début ou la fin du fichier est supprimé quelle que soit sa durée, et le premier clip garde sa position (0,47 s moins la marge de début de 100 ms). C'est la ligne keep |= plus haut.
La longueur minimale décide de la forme
Avec les marges par défaut (5 ms et 20 ms), seule la longueur minimale change :
| durée minimale | clips anglais | sortie anglais | clips japonais | sortie japonais |
|---|---|---|---|---|
| 20 ms | 18 | 11,40 s | 19 | 14,08 s |
| 50 ms (défaut) | 11 | 11,46 s | 14 | 14,15 s |
| 100 ms | 7 | 11,63 s | 11 | 14,30 s |
| 200 ms | 6 | 11,73 s | 6 | 15,00 s |
| 300 ms | 5 | 11,98 s | 5 | 15,20 s |
| 500 ms | 5 | 11,98 s | 5 | 15,20 s |
Les deux prises contiennent cinq phrases. Avec les 50 ms par défaut, la prise anglaise ressort en 11 clips et la japonaise en 14 : les courts blancs à l'intérieur des phrases (6 entre 50 et 500 ms dans la prise anglaise, 9 dans la japonaise) comptent comme du silence et sont coupés. La sortie n'est qu'une demi-seconde plus courte qu'à 500 ms, mais chacune de ces coupes enlève une respiration ou l'espace entre deux mots, et le résultat sonne haché. À partir de 300 ms, chaque prise donne exactement cinq clips, un par phrase. La durée totale bouge à peine ; ce qui change, c'est l'endroit des coupes.
Les marges décident du rythme
Avec le minimum à 500 ms, les marges :
| marge début / fin | sortie anglais | sortie japonais |
|---|---|---|
| 0 / 0 ms | 11,86 s | 15,08 s |
| 5 / 20 ms | 11,98 s | 15,20 s |
| 50 / 100 ms | 12,56 s | 15,78 s |
| 100 / 300 ms | 13,76 s | 16,98 s |
| 200 / 500 ms | 15,16 s | 18,38 s |
Une fois les trous refermés, la pause entre deux phrases vaut la marge de fin de l'une plus la marge de début de la suivante. À 0/0, les phrases se collent ; à 100/300, chaque raccord est une respiration de 0,4 seconde, à peu près ce que laisse quelqu'un qui lit à un rythme régulier. La marge de début a un second rôle : les attaques douces des mots passent souvent sous le seuil et, sans elle, la première consonne de la phrase disparaît. La marge de fin, elle, se règle à l'oreille.
Le seuil doit dépasser les crêtes du bruit, pas sa moyenne
Avec 500 ms et des marges de 100/300, en faisant varier le seuil :
| seuil | clips anglais | sortie anglais | clips japonais | sortie japonais |
|---|---|---|---|---|
| −36 dB | 5 | 13,29 s | 5 | 16,79 s |
| −40 dB | 5 | 13,34 s | 5 | 16,87 s |
| −44 dB | 5 | 13,40 s | 5 | 16,94 s |
| −48 dB | 5 | 13,76 s | 5 | 16,98 s |
| −52 dB | 10 | 17,48 s | 13 | 22,78 s |
| −56 dB | 1 | 27,57 s | 1 | 33,55 s |
De −36 à −48 dB, le résultat est stable ; un seuil plus haut rogne seulement un peu plus de la fin douce de chaque mot. À −52 dB, juste au-dessus des crêtes du souffle, quelques échantillons de souffle franchissent la ligne et cassent les longues pauses en morceaux de moins de 500 ms, si bien que plusieurs d'entre elles survivent. À −56 dB, le souffle compte comme du son partout et rien n'est supprimé. −56 a l'air d'un choix sûr si l'on sait seulement que le bruit de fond tourne autour de −64 dB RMS. Comme la règle compare des échantillons isolés, c'est la crête du souffle qui compte, et le seuil a besoin de quelques dB de marge au-dessus.
Le même travail avec ffmpeg
ffmpeg propose deux filtres utiles ici. silencedetect se contente de signaler et, avec le même seuil et le même minimum, trouve les mêmes pauses que la reconstruction :
ffmpeg -i en-take.wav -af silencedetect=noise=-48dB:d=0.5 -f null - 2>&1 | grep silence_ # silence_start: 2.411833 # silence_end: 6.070167 | silence_duration: 3.658333 # ... La première phrase se termine à 2,4118 s, et le premier clip de la reconstruction se termine à 2,712 s, soit le même point plus la marge de fin de 300 ms. Remarquez ce qui manque dans cette sortie : les 0,47 s de calme au début du fichier, plus courtes que d, ne sont pas signalées.
silenceremove coupe directement, et c'est là que les attentes se trompent. Avec ffmpeg 7.1 :
ffmpeg -i en-take.wav -af "silenceremove=start_periods=1:start_threshold=-48dB:\ stop_periods=-1:stop_threshold=-48dB:stop_duration=0.5:stop_silence=0:detection=peak" out.wav | réglages du filtre | sortie anglais | sortie japonais |
|---|---|---|
| detection=peak, stop_silence=0 | 13,98 s | 17,20 s |
| detection=peak, stop_silence=0.3 | 15,48 s | 18,70 s |
| detection=rms, stop_silence=0 | 13,50 s | 17,03 s |
| numpy, 500 ms, marges 0/0 | 11,86 s | 15,08 s |
Avec stop_silence=0, la sortie reste environ 2 secondes plus longue qu'une suppression sans marge. Passer silencedetect dessus montre pourquoi : chaque pause supprimée a laissé environ 0,52 s derrière elle. stop_duration est à la fois la longueur minimale et la quantité de silence que le filtre laisse passer avant de commencer à couper ; demander « ne retire que les pauses de plus de 500 ms » revient donc aussi à dire « garde 500 ms de chacune ». stop_silence s'ajoute ensuite à cela au lieu de le remplacer, et il n'existe aucune option pour une marge avant la parole. Si les pauses doivent avoir une durée choisie, silencedetect plus votre propre liste de coupes (ou la version numpy ci-dessus) donne ce contrôle ; silenceremove convient très bien quand une pause d'une demi-seconde est justement ce que vous vouliez.
Le script complet, avec tous les balayages ci-dessus, fait environ 90 lignes de Python avec numpy et soundfile. Dans l'application de bureau osFoundry, les mêmes étapes sont Edit → Strip Silence et Edit → Space Clips… dans le Sound Editor, et elles tournent sur votre propre machine, conformément à la conception local-first de l'application. L'application est gratuite depuis la page de téléchargement d'osFoundry.

