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..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 @@ -166,3 +167,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_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 + 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_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 + 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)