From 186f46b23e0ac636b7d8f605d09e21d09a5b52c7 Mon Sep 17 00:00:00 2001 From: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:45:39 +0530 Subject: [PATCH 1/2] FIX AudioSpeedConverter: document that pitch shifts with speed and reject empty output The converter resamples the signal, so pitch changes together with speed (2x turns 440 Hz into 880 Hz), but the docstrings and the audio converter docs said pitch was preserved. Correct the docs, and raise a clear ValueError when the speed factor is so large that the output would have zero samples instead of silently writing empty audio. Fixes #3714 Signed-off-by: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com> --- doc/code/converters/2_audio_converters.ipynb | 4 +-- doc/code/converters/2_audio_converters.py | 4 +-- pyrit/converter/audio_speed_converter.py | 19 ++++++++-- .../converter/test_audio_speed_converter.py | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/doc/code/converters/2_audio_converters.ipynb b/doc/code/converters/2_audio_converters.ipynb index 4bde5c0a8f..a1f2a11b3a 100644 --- a/doc/code/converters/2_audio_converters.ipynb +++ b/doc/code/converters/2_audio_converters.ipynb @@ -125,7 +125,7 @@ "\n", "Available converters:\n", "- **`AudioFrequencyConverter`** — Shifts the audio frequency (pitch) higher\n", - "- **`AudioSpeedConverter`** — Changes playback speed without altering pitch\n", + "- **`AudioSpeedConverter`** — Changes playback speed (pitch shifts with the speed)\n", "- **`AudioVolumeConverter`** — Scales the amplitude (louder or quieter)\n", "- **`AudioEchoConverter`** — Adds an echo effect with configurable delay and decay\n", "- **`AudioWhiteNoiseConverter`** — Mixes white noise into the signal" @@ -167,7 +167,7 @@ "converted = await audio_frequency_converter.convert_async(prompt=prompt) # type: ignore\n", "print(\"Frequency shift:\", converted)\n", "\n", - "# Speed change — speeds up (>1.0) or slows down (<1.0) without pitch change\n", + "# Speed change — speeds up (>1.0) or slows down (<1.0); pitch shifts with it\n", "audio_speed_converter = AudioSpeedConverter(speed_factor=0.5)\n", "converted = await audio_speed_converter.convert_async(prompt=prompt) # type: ignore\n", "print(\"Speed (0.5x):\", converted)\n", diff --git a/doc/code/converters/2_audio_converters.py b/doc/code/converters/2_audio_converters.py index fea3103952..296fd7bc0c 100644 --- a/doc/code/converters/2_audio_converters.py +++ b/doc/code/converters/2_audio_converters.py @@ -78,7 +78,7 @@ # # Available converters: # - **`AudioFrequencyConverter`** — Shifts the audio frequency (pitch) higher -# - **`AudioSpeedConverter`** — Changes playback speed without altering pitch +# - **`AudioSpeedConverter`** — Changes playback speed (pitch shifts with the speed) # - **`AudioVolumeConverter`** — Scales the amplitude (louder or quieter) # - **`AudioEchoConverter`** — Adds an echo effect with configurable delay and decay # - **`AudioWhiteNoiseConverter`** — Mixes white noise into the signal @@ -101,7 +101,7 @@ converted = await audio_frequency_converter.convert_async(prompt=prompt) # type: ignore print("Frequency shift:", converted) -# Speed change — speeds up (>1.0) or slows down (<1.0) without pitch change +# Speed change — speeds up (>1.0) or slows down (<1.0); pitch shifts with it audio_speed_converter = AudioSpeedConverter(speed_factor=0.5) converted = await audio_speed_converter.convert_async(prompt=prompt) # type: ignore print("Speed (0.5x):", converted) diff --git a/pyrit/converter/audio_speed_converter.py b/pyrit/converter/audio_speed_converter.py index e0534de166..7881654225 100644 --- a/pyrit/converter/audio_speed_converter.py +++ b/pyrit/converter/audio_speed_converter.py @@ -19,12 +19,16 @@ class AudioSpeedConverter(Converter): """ - Changes the playback speed of an audio file without altering pitch or other audio characteristics. + Changes the playback speed of an audio file. A speed_factor > 1.0 speeds up the audio (shorter duration), while a speed_factor < 1.0 slows it down (longer duration). The converter resamples the audio signal using interpolation so that the sample rate, bit depth, and number of channels remain unchanged. + + Because the samples are resampled rather than time-stretched, the pitch + changes together with the speed, like playing a tape faster or slower. + For example, a speed_factor of 2.0 raises a 440 Hz tone to about 880 Hz. """ SUPPORTED_INPUT_TYPES = ("audio_path",) @@ -96,10 +100,18 @@ def _resample_audio(self, data: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]: Returns: Resampled audio data with the original dtype preserved. + + Raises: + ValueError: If the speed factor would leave no samples in the output. """ original_dtype = data.dtype num_samples = len(data) if data.ndim == 1 else data.shape[0] new_num_samples = int(num_samples / self._speed_factor) + if new_num_samples < 1: + raise ValueError( + f"speed_factor {self._speed_factor} is too large for an input of {num_samples} samples; " + "the converted audio would be empty." + ) if data.ndim == 1: return self._resample_channel(data, new_num_samples).astype(original_dtype) @@ -113,7 +125,7 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "audi The audio is resampled via interpolation so that the output has a different number of samples (and therefore a different duration) while keeping the - original sample rate. This preserves the pitch and tonal qualities of the audio. + original sample rate. The pitch shifts by the same factor as the speed. Args: prompt (str): File path to the audio file to be converted. @@ -123,7 +135,8 @@ async def convert_async(self, *, prompt: str, input_type: PromptDataType = "audi ConverterResult: The result containing the converted audio file path. Raises: - ValueError: If the input type is not supported. + ValueError: If the input type is not supported, or if the speed factor + would leave no samples in the converted audio. Exception: If there is an error during the conversion process. """ if not self.input_supported(input_type): diff --git a/tests/unit/converter/test_audio_speed_converter.py b/tests/unit/converter/test_audio_speed_converter.py index 0fb165068a..97d05fc907 100644 --- a/tests/unit/converter/test_audio_speed_converter.py +++ b/tests/unit/converter/test_audio_speed_converter.py @@ -166,3 +166,39 @@ def test_speed_identifier_is_stable_for_equal_settings() -> None: AudioSpeedConverter(speed_factor=0.5).get_identifier().hash == AudioSpeedConverter(speed_factor=0.5).get_identifier().hash ) + + +async def test_speed_up_shifts_pitch_by_speed_factor(sqlite_instance): + """Resampling shifts pitch with speed, as the docstring now says: 2x turns a 440 Hz tone into about 880 Hz.""" + sample_rate = 16000 + t = np.arange(sample_rate) / sample_rate + tone = (0.5 * 32767 * np.sin(2 * np.pi * 440 * t)).astype(np.int16) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_wav_file: + original_wav_path = temp_wav_file.name + wavfile.write(original_wav_path, sample_rate, tone) + + converter = AudioSpeedConverter(speed_factor=2.0) + result = await converter.convert_async(prompt=original_wav_path) + + out_rate, out_data = wavfile.read(result.output_text) + spectrum = np.abs(np.fft.rfft(out_data.astype(np.float64))) + peak_hz = np.fft.rfftfreq(len(out_data), 1 / out_rate)[spectrum.argmax()] + assert abs(peak_hz - 880) < 10 + + os.remove(original_wav_path) + if os.path.exists(result.output_text): + os.remove(result.output_text) + + +async def test_speed_factor_too_large_for_input_raises(sqlite_instance): + """A speed factor that would leave zero samples should raise instead of writing empty audio.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_wav_file: + original_wav_path = temp_wav_file.name + wavfile.write(original_wav_path, 16000, np.array([100, -100], dtype=np.int16)) + + converter = AudioSpeedConverter(speed_factor=100) + with pytest.raises(ValueError, match="converted audio would be empty"): + await converter.convert_async(prompt=original_wav_path) + + os.remove(original_wav_path) From e06b1383bff26174e09f82c4ad8a9d1fee79ab85 Mon Sep 17 00:00:00 2001 From: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:47:09 +0530 Subject: [PATCH 2/2] TEST: Follow the async test naming and annotation style in the new AudioSpeedConverter tests Signed-off-by: KVR Vishnu <46535652+VISHNU0906@users.noreply.github.com> --- tests/unit/converter/test_audio_speed_converter.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/unit/converter/test_audio_speed_converter.py b/tests/unit/converter/test_audio_speed_converter.py index 97d05fc907..a0d2762454 100644 --- a/tests/unit/converter/test_audio_speed_converter.py +++ b/tests/unit/converter/test_audio_speed_converter.py @@ -10,6 +10,7 @@ from scipy.io import wavfile from pyrit.converter.audio_speed_converter import AudioSpeedConverter +from pyrit.memory import SQLiteMemory from pyrit.registry import ConverterRegistry @@ -168,7 +169,7 @@ def test_speed_identifier_is_stable_for_equal_settings() -> None: ) -async def test_speed_up_shifts_pitch_by_speed_factor(sqlite_instance): +async def test_speed_up_shifts_pitch_by_speed_factor_async(sqlite_instance: SQLiteMemory) -> None: """Resampling shifts pitch with speed, as the docstring now says: 2x turns a 440 Hz tone into about 880 Hz.""" sample_rate = 16000 t = np.arange(sample_rate) / sample_rate @@ -191,7 +192,7 @@ async def test_speed_up_shifts_pitch_by_speed_factor(sqlite_instance): os.remove(result.output_text) -async def test_speed_factor_too_large_for_input_raises(sqlite_instance): +async def test_speed_factor_too_large_for_input_raises_async(sqlite_instance: SQLiteMemory) -> None: """A speed factor that would leave zero samples should raise instead of writing empty audio.""" with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as temp_wav_file: original_wav_path = temp_wav_file.name