Pausen aus einer Sprachaufnahme zu entfernen, läuft auf vier Zahlen hinaus: einen Pegel, unter dem Audio als still gilt, wie lange ein leiser Abschnitt mindestens dauern muss, bevor er entfernt wird, und wie viel auf beiden Seiten jedes verbleibenden Stücks stehen bleibt. Der Sound Editor in der Desktop-App von osFoundry zeigt genau diese vier im Dialog Strip Silence, mit den Standardwerten −48 dB, 50 ms, 5 ms und 20 ms. Unten wird dieselbe Operation in rund 40 Zeilen numpy nachgebaut, Sample für Sample gegen eine von der App exportierte Datei geprüft und dann benutzt, um zu messen, was jede Zahl tatsächlich bewirkt. Am Ende kommt dieselbe Aufgabe mit silenceremove von ffmpeg, das sich an einer leicht zu übersehenden Stelle anders verhält.

Die Testaufnahmen

Zwei eigens dafür gebaute Test-Takes: je fünf kurze Text-to-Speech-Sätze (Englisch und Japanisch), getrennt durch 2,4 bis 3,4 Sekunden Ruhe, darunter generiertes Raumrauschen. 48 kHz, mono, 16 Bit.

Take Länge Rauschen RMS Rauschen Spitze
Englisch 27,57 s −64,6 dBFS −52,4 dBFS
Japanisch 33,55 s −64,6 dBFS −52,4 dBFS

Der Abstand zwischen den letzten beiden Spalten wird später wichtig. Rauschen mit einem Mittel von −64,6 dBFS hat trotzdem einzelne Samples, die −52,4 erreichen.

Die Regel

Ein Sample ist leise, wenn sein Betrag auf oder unter dem Schwellwert liegt. Eine Folge leiser Samples darf entfernt werden, wenn sie mindestens die Mindestlänge dauert. Alles zwischen entfernbaren Folgen wird zu einem Clip, erweitert um das Padding am Anfang und am Ende:

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 

Strip Silence entfernt nur. Die Lücken zu schließen ist ein eigener Schritt, im Sound Editor heißt er Space Clips: die Clips Ende an Anfang setzen, den ersten dort lassen, wo er war.

def space_clips(x, clips): return np.concatenate([np.zeros(clips[0][0], x.dtype)] + [x[s:e] for s, e in clips]) 

Abgleich mit der App

Beide Takes liefen durch den Sound Editor mit −48 dB, 500 ms, 100 ms und 300 ms, dann Space Clips, dann Bounce Mix, normalisiert auf −0,1 dBFS. Die Datei der App ist 24-Bit-Stereo mit identischen Kanälen. Im Vergleich mit dem numpy-Ergebnis, gleich normalisiert:

Take Bounce der App numpy größte Abweichung
Englisch 660.500 Samples (13,76 s) 660.500 Samples −135,0 dB
Japanisch 815.030 Samples (16,98 s) 815.030 Samples −132,5 dB

Gleiche Länge aufs Sample genau und Abweichungen in der Größenordnung von 24-Bit-Rundung. Ein schlichter Vergleich pro Sample, ohne RMS-Fenster und ohne Glättung, reicht aus, um zu reproduzieren, was die App exportiert.

Die erste Version des Nachbaus lag in genau einem Bereich daneben, und das ist die eine Stelle, an der eine naive Implementierung falschliegt. Jeder Take beginnt mit 0,47 Sekunden Ruhe vor dem ersten Wort, kürzer als die Mindestlänge von 500 ms. Wörtlich angewendet behält die Regel diesen Abschnitt, und die Ausgabe beginnt mit 0,47 s Rauschen. Die Datei der App beginnt dagegen mit 0,376 s digitaler Null und dann dem ersten Wort: Stille, die den Anfang oder das Ende der Datei berührt, wird unabhängig von ihrer Länge entfernt, und der erste Clip behält seine Position (0,47 s minus 100 ms Padding am Anfang). Das ist die Zeile keep |= oben.

Die Mindestlänge bestimmt die Form

Mit dem Standard-Padding (5 ms und 20 ms) ändert sich nur die Mindestlänge:

Mindestdauer Clips Englisch Ausgabe Englisch Clips Japanisch Ausgabe Japanisch
20 ms 18 11,40 s 19 14,08 s
50 ms (Standard) 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

Beide Takes haben fünf Sätze. Beim Standardwert von 50 ms kommt der englische Take als 11 Clips zurück, der japanische als 14: Die kurzen Lücken innerhalb der Sätze (im englischen Take 6 zwischen 50 und 500 ms, im japanischen 9) zählen als Stille und werden herausgeschnitten. Die Ausgabe ist nur eine halbe Sekunde kürzer als bei 500 ms, aber jeder dieser Schnitte nimmt einen Atemzug oder den Abstand zwischen zwei Wörtern weg, und das Ergebnis klingt abgehackt. Ab 300 ms ist jeder Take genau fünf Clips, einer pro Satz. Die Gesamtlänge bewegt sich kaum; was sich ändert, ist, wo die Schnitte liegen.

Das Padding bestimmt den Rhythmus

Mit der Mindestlänge bei 500 ms, das Padding:

Padding Anfang / Ende Ausgabe Englisch Ausgabe Japanisch
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

Nachdem die Lücken geschlossen sind, ist die Pause zwischen zwei Sätzen das End-Padding des einen plus das Start-Padding des nächsten. Bei 0/0 stoßen die Sätze direkt aneinander; bei 100/300 ist jeder Übergang ein Atemzug von 0,4 Sekunden, ungefähr das, was ein Sprecher beim gleichmäßigen Vorlesen lässt. Das Start-Padding hat noch eine zweite Aufgabe: Weiche Wortanfänge liegen oft unter dem Schwellwert, und ohne Padding fehlt der erste Konsonant eines Satzes. Das End-Padding ist der Wert, den man nach Gehör einstellt.

Der Schwellwert muss über den Rauschspitzen liegen, nicht über dem Mittel

Mit 500 ms und Padding 100/300, der Schwellwert durchgefahren:

Schwellwert Clips Englisch Ausgabe Englisch Clips Japanisch Ausgabe Japanisch
−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

Von −36 bis −48 dB ist das Ergebnis stabil; ein höherer Schwellwert schneidet nur etwas mehr vom leisen Ausklang jedes Wortes ab. Bei −52 dB, knapp über den Rauschspitzen, gehen vereinzelte Rausch-Samples über die Linie und zerlegen die langen Pausen in Stücke unter 500 ms, sodass mehrere davon stehen bleiben. Bei −56 dB zählt das Rauschen überall als Signal, und es wird gar nichts entfernt. −56 wirkt wie eine sichere Wahl, wenn man nur weiß, dass das Grundrauschen bei etwa −64 dB RMS liegt. Weil die Regel einzelne Samples vergleicht, zählt aber die Spitze des Rauschens, und der Schwellwert braucht ein paar dB Abstand darüber.

Dieselbe Aufgabe mit ffmpeg

ffmpeg hat zwei passende Filter. silencedetect meldet nur, und mit demselben Schwellwert und derselben Mindestlänge findet es dieselben Pausen wie der Nachbau:

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 # ... 

Der erste Satz endet bei 2,4118 s, und der erste Clip des Nachbaus endet bei 2,712 s, also an derselben Stelle plus 300 ms End-Padding. Auffällig ist, was in dieser Ausgabe fehlt: Die 0,47 s Ruhe am Dateianfang, kürzer als d, werden nicht gemeldet.

silenceremove schneidet direkt, und genau hier liegen die Erwartungen daneben. Mit 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 
Filtereinstellungen Ausgabe Englisch Ausgabe Japanisch
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, Padding 0/0 11,86 s 15,08 s

Mit stop_silence=0 ist die Ausgabe immer noch etwa 2 Sekunden länger als ein Schnitt ohne Padding. Lässt man silencedetect darüber laufen, sieht man den Grund: Jede entfernte Pause hat etwa 0,52 s zurückgelassen. stop_duration ist gleichzeitig die Mindestlänge und die Menge an Stille, die der Filter durchlässt, bevor er zu schneiden beginnt. Wer „nur Pausen über 500 ms entfernen“ verlangt, bekommt also auch „500 ms von jeder Pause stehen lassen“. stop_silence kommt dann noch obendrauf, statt das zu ersetzen, und eine Option für Padding vor der Sprache gibt es nicht. Sollen die Pausen eine bestimmte Länge haben, gibt einem silencedetect plus eine eigene Schnittliste (oder die numpy-Version oben) diese Kontrolle; silenceremove passt, wenn eine halbe Sekunde Pause ohnehin das Ziel war.

Der Dialog Strip Silence im Sound Editor von osFoundry, eingestellt auf −48 dB, 500 ms, 100 ms und 300 ms

Das vollständige Skript mit allen Messreihen oben sind rund 90 Zeilen Python mit numpy und soundfile. In der Desktop-App von osFoundry sind dieselben Schritte Edit → Strip Silence und Edit → Space Clips… im Sound Editor, und sie laufen auf dem eigenen Rechner, passend zum Local-first-Ansatz der App. Die App gibt es kostenlos auf der Download-Seite von osFoundry.