Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/code/converters/2_audio_converters.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions doc/code/converters/2_audio_converters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
19 changes: 16 additions & 3 deletions pyrit/converter/audio_speed_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",)
Expand Down Expand Up @@ -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)
Expand All @@ -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.
Expand All @@ -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):
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/converter/test_audio_speed_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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)
Loading