random-looper 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ Copyright © 2026 Lorekeeper49
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
+
5
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
+
7
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: random-looper
3
+ Version: 0.1.0
4
+ Summary: Play an audio file forever with randomized, crossfaded loop points.
5
+ Author-email: Your Name <you@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Lorekeeper49/random-looper
8
+ Keywords: audio,looping,ambient,crossfade,soundfile
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Multimedia :: Sound/Audio
11
+ Classifier: Environment :: Console
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: numpy>=1.21
16
+ Requires-Dist: soundfile>=0.12
17
+ Requires-Dist: sounddevice>=0.4.6
18
+ Provides-Extra: render-only
19
+ Requires-Dist: numpy>=1.21; extra == "render-only"
20
+ Requires-Dist: soundfile>=0.12; extra == "render-only"
21
+ Dynamic: license-file
22
+
23
+ # Random Looper
24
+
25
+ Play an audio file forever with **randomized, crossfaded loop points**, and optionally render an extended copy of it to any length you like.
26
+
27
+ Every time the audio loops, the script picks a brand-new random "jump from" point and "jump to" point, then hides the jump with a crossfade. Because the points change on every loop, the result never repeats in the same way twice.
28
+
29
+ > ## ⚠️ Read this first: this is for atmosphere, not music
30
+ >
31
+ > This kind of looping only really works for **ambient / atmospheric audio**: rain, wind, ocean waves, forest or jungle ambience, a crackling fire, room tone, crowd murmur, an engine hum, a synth drone, and similar steady textures.
32
+ >
33
+ > It will **not** work well on **music**. Music has rhythm, melody, harmony and structure. Jumping between two random points means:
34
+ >
35
+ > - the beat lands in the wrong place, so the tempo feels like it stumbles,
36
+ > - the chords or key change abruptly or clash,
37
+ > - phrases, lyrics and builds get cut off or repeated mid-thought,
38
+ > - a crossfade between two unrelated musical passages just sounds like two songs playing over each other (muddy, phasing, "mashup" sound).
39
+ >
40
+ > Atmosphere survives this because it has no beat, melody or story. Any 10-second slice sounds much like any other, so a smooth blend between two random slices is nearly invisible.
41
+ >
42
+ > Even with atmosphere, **distinct one-off sounds** (a single bird call, a thunderclap, a door slam, a voice) can end up cut in half, doubled, or repeated suspiciously often. Use `--exclude` (below) to keep loop points away from those moments.
43
+
44
+ ---
45
+
46
+ ## Install
47
+
48
+ From PyPI:
49
+
50
+ ```Shell
51
+ pip install random-looper
52
+ ```
53
+
54
+ From this repo (editable, for development):
55
+
56
+ ```Shell
57
+ pip install -e .
58
+ ```
59
+
60
+ Once installed you get a `random-looper` command:
61
+
62
+ ```Shell
63
+ random-looper rain.wav
64
+ random-looper ocean.ogg -e 10:00 -o ocean_10min.wav
65
+ ```
66
+
67
+ Or run it as a module:
68
+
69
+ ```Shell
70
+ python -m random_looper rain.wav
71
+ ```
72
+
73
+ ## Manual Install
74
+
75
+ Requires Python 3.8+.
76
+
77
+ ```Shell
78
+ pip install numpy soundfile sounddevice
79
+ ```
80
+
81
+ - `numpy` and `soundfile` are needed for everything.
82
+ - `sounddevice` is only needed for live playback. If you only use `--no-play`, you can skip it.
83
+ - WAV, FLAC and OGG work out of the box. MP3 needs libsndfile 1.1 or newer, which recent `soundfile` wheels include.
84
+
85
+ Or you can install from `requirements.txt` using `pip install -r requirments.txt`.
86
+
87
+ If you're on Linux, you also need the PortAudio system library for sounddevice to work, use `sudo apt install libportaudio2` to install it if you haven't already.
88
+
89
+ ---
90
+
91
+ ## Quick start
92
+
93
+ ```Shell
94
+ python random_looper.py rain.wav
95
+ ```
96
+
97
+ Play `rain.wav` forever with random loop points. Press **Ctrl+C** to stop.
98
+
99
+ ```Shell
100
+ python random_looper.py # (it asks you for the file path)
101
+ ```
102
+
103
+ ```Shell
104
+ python random_looper.py forest.flac -x "[(0,15),(60,75.5)]"
105
+ ```
106
+
107
+ Same, but never choose a loop point in 0-15s or 60-75.5s (for example, to avoid an intro or a loud bird call).
108
+
109
+ ```Shell
110
+ python random_looper.py ocean.ogg -e 10:00 -o ocean_10min.wav
111
+ ```
112
+
113
+ Write a 10-minute version to `ocean_10min.wav`, then play the original with random loops.
114
+
115
+ ```Shell
116
+ python random_looper.py wind.mp3 -e 1:00:00 --no-play
117
+ ```
118
+
119
+ Just write a one-hour file and don't play anything.
120
+
121
+ ---
122
+
123
+ ## Arguments
124
+
125
+ | Argument | Description | Default |
126
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
127
+ | `audio` | Path to the audio file. Optional: if you leave it out, the script prompts you for it. Quotes around the path are fine. | prompt |
128
+ | `-x`, `--exclude RANGES` | Time ranges, in **seconds**, that loop points may not land in. See [Exclusion ranges](#exclusion-ranges). | none |
129
+ | `-e`, `--extend LENGTH` | Also write an extended copy of the audio of exactly this length. Accepts seconds (`90`, `90.5`) or `mm:ss` / `h:mm:ss` (`10:00`, `1:30:00`). | off |
130
+ | `-o`, `--output PATH` | Where to write the extended file. The format comes from the extension (`.wav`, `.flac`, `.ogg`...). Only used with `--extend`. | `<name>_extended.wav` next to the input file |
131
+ | `--no-play` | Don't play anything. Useful with `--extend` to just make the file. | off (plays) |
132
+ | `--no-log` | Don't print the startup info line or the loop points while playing. | off (prints) |
133
+ | `-c`, `--crossfade SECONDS` | Length of the crossfade used to hide each jump. | `2.0` |
134
+ | `-m`, `--min-length SECONDS` | Minimum amount of audio **played before each jump** and **inside each loop** (the distance between the jump-to and jump-from points). Larger values mean longer stretches between jumps. | `10.0` |
135
+ | `--fade-out SECONDS` | Length of the fade-out at the very end of the extended file. Capped at a quarter of the extended length. Only used with `--extend`. | `4.0` |
136
+ | `--seed N` | Seed the random number generator so you get the same sequence of loop points every run. | random |
137
+
138
+ ### Automatic adjustments for short files
139
+
140
+ So that short files don't break the algorithm, the script clamps your settings:
141
+
142
+ - crossfade is limited to 1/8 of the file length (and is at least 0.05 s),
143
+ - min-length is limited to 1/3 of the file length, but never less than twice the crossfade.
144
+
145
+ If it changes your values, it prints the values it used.
146
+
147
+ ### Exclusion ranges
148
+
149
+ `-x` takes a Python-style list of `(start, end)` pairs in seconds:
150
+
151
+ ```Shell
152
+ -x "[(0, 15), (60, 75.5)]"
153
+ ```
154
+
155
+ - Wrap it in quotes so your shell/command prompt doesn't mangle the brackets and commas.
156
+ - A single pair works too: `-x "(0,15)"`.
157
+ - Overlapping ranges are merged, and a pair given backwards (`(20,10)`) is flipped.
158
+ - The ranges apply to **both** loop points: the point it jumps **from** and the point it jumps **to**. Neither will ever be picked inside an excluded range.
159
+ - Playback itself can still pass through excluded areas on its way. They are only off-limits as loop points.
160
+ - The crossfade window sits just *before* each loop point, so a crossfade can still overlap the edge of an excluded range. If that matters, make the range a little bigger.
161
+ - If your exclusions leave too little room for a loop (for example, you excluded almost the whole file), the script stops with an error telling you to shrink them or lower `--min-length` / `--crossfade`.
162
+
163
+ ---
164
+
165
+ ## How it works
166
+
167
+ 1. **Load.** The file is decoded into memory as 32-bit float audio.
168
+ 2. **Plan a jump.** For every loop the script picks a random pair of points, **B** (where it jumps *from*) and **A** (where it jumps *to*), with A earlier than B. The picks are uniform over all the allowed time (everything not excluded), and always satisfy:
169
+ - neither point is inside an excluded range,
170
+ - at least `--min-length` seconds play before the jump,
171
+ - at least `--min-length` seconds sit between A and B,
172
+ - A is at least one crossfade length into the file (there has to be audio before it to fade in),
173
+ - a valid next jump always exists, so the script can never paint itself into a corner.
174
+ 3. **Crossfade.** The audio just before B (the last `--crossfade` seconds) fades out while the audio just before A fades in, using an **equal-power** curve so the volume stays steady through the blend. When the crossfade finishes, playback continues from A as if nothing happened.
175
+ 4. **Repeat.** Playback continues to the next randomly chosen B, jumps back to a new random A, and so on forever. The first pass starts at the beginning of the file.
176
+
177
+ ### Playback
178
+
179
+ A background thread prepares audio in pieces (the normal stretch, then the crossfaded jump, and so on) and feeds a small queue. The sound card callback just copies from that queue, so the heavy lifting never happens in the audio thread. Each time a jump starts, a line like this is printed (unless `--no-log` is set):
180
+
181
+ ```
182
+ loop: 2:14.30 -> 0:45.20
183
+ ```
184
+
185
+ That means "jumped from 2:14.30 back to 0:45.20".
186
+
187
+ ### Extended file
188
+
189
+ With `--extend`, the same jump logic is run offline and streamed straight to disk, so even very long outputs don't need to fit in memory. The file is trimmed to exactly the length you asked for and ends with a fade-out (`--fade-out`). A progress percentage is shown while it renders. After rendering, the script plays the original unless you pass `--no-play`.
190
+
191
+ Output is 16-bit PCM for WAV by default. Samples are clipped to the valid range, since an equal-power crossfade of very similar loud audio can briefly exceed full scale.
192
+
193
+ ---
194
+
195
+ ## Tips for good results
196
+
197
+ - Pick **steady, texture-like** recordings with no obvious beginning, end or repeating pattern.
198
+ - If the source has a fade-in, fade-out, or a distinct event you don't want repeated or chopped, cover it with `--exclude`.
199
+ - Longer `--crossfade` (3-6 s) hides jumps better on very smooth sounds like drones. Shorter is fine for noisy textures like rain.
200
+ - A larger `--min-length` makes the loop feel slower and more natural; a smaller one means more frequent (but still smooth) jumps.
201
+ - Use `--seed` if you find a render you like and want to reproduce it.
202
+ - Very short source files give the randomizer little to work with, and repetition becomes easy to notice. Longer recordings (a minute or more) work best.
203
+
204
+ ---
205
+
206
+ ## Troubleshooting
207
+
208
+ | Problem | Fix |
209
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
210
+ | `Playback needs sounddevice` | `pip install sounddevice` |
211
+ | `File not found` | Check the path. Put quotes around paths with spaces. |
212
+ | `Could not read audio file` | The format may not be supported by your libsndfile build. Convert the file to WAV or FLAC. |
213
+ | `Could not parse --exclude` | Use the form `"[(10, 20), (45.5, 60)]"`, with the quotes. |
214
+ | `The exclusion ranges leave too little room...` | Shrink the exclusions, or lower `--min-length` / `--crossfade`. |
215
+ | Audible clicks or stutters in playback | Your machine may be too busy to keep the audio queue filled. Close other heavy programs. |
216
+ | Jumps sound obvious | The source is probably not stationary enough. See the warning at the top about music and distinct sounds. |
@@ -0,0 +1,194 @@
1
+ # Random Looper
2
+
3
+ Play an audio file forever with **randomized, crossfaded loop points**, and optionally render an extended copy of it to any length you like.
4
+
5
+ Every time the audio loops, the script picks a brand-new random "jump from" point and "jump to" point, then hides the jump with a crossfade. Because the points change on every loop, the result never repeats in the same way twice.
6
+
7
+ > ## ⚠️ Read this first: this is for atmosphere, not music
8
+ >
9
+ > This kind of looping only really works for **ambient / atmospheric audio**: rain, wind, ocean waves, forest or jungle ambience, a crackling fire, room tone, crowd murmur, an engine hum, a synth drone, and similar steady textures.
10
+ >
11
+ > It will **not** work well on **music**. Music has rhythm, melody, harmony and structure. Jumping between two random points means:
12
+ >
13
+ > - the beat lands in the wrong place, so the tempo feels like it stumbles,
14
+ > - the chords or key change abruptly or clash,
15
+ > - phrases, lyrics and builds get cut off or repeated mid-thought,
16
+ > - a crossfade between two unrelated musical passages just sounds like two songs playing over each other (muddy, phasing, "mashup" sound).
17
+ >
18
+ > Atmosphere survives this because it has no beat, melody or story. Any 10-second slice sounds much like any other, so a smooth blend between two random slices is nearly invisible.
19
+ >
20
+ > Even with atmosphere, **distinct one-off sounds** (a single bird call, a thunderclap, a door slam, a voice) can end up cut in half, doubled, or repeated suspiciously often. Use `--exclude` (below) to keep loop points away from those moments.
21
+
22
+ ---
23
+
24
+ ## Install
25
+
26
+ From PyPI:
27
+
28
+ ```Shell
29
+ pip install random-looper
30
+ ```
31
+
32
+ From this repo (editable, for development):
33
+
34
+ ```Shell
35
+ pip install -e .
36
+ ```
37
+
38
+ Once installed you get a `random-looper` command:
39
+
40
+ ```Shell
41
+ random-looper rain.wav
42
+ random-looper ocean.ogg -e 10:00 -o ocean_10min.wav
43
+ ```
44
+
45
+ Or run it as a module:
46
+
47
+ ```Shell
48
+ python -m random_looper rain.wav
49
+ ```
50
+
51
+ ## Manual Install
52
+
53
+ Requires Python 3.8+.
54
+
55
+ ```Shell
56
+ pip install numpy soundfile sounddevice
57
+ ```
58
+
59
+ - `numpy` and `soundfile` are needed for everything.
60
+ - `sounddevice` is only needed for live playback. If you only use `--no-play`, you can skip it.
61
+ - WAV, FLAC and OGG work out of the box. MP3 needs libsndfile 1.1 or newer, which recent `soundfile` wheels include.
62
+
63
+ Or you can install from `requirements.txt` using `pip install -r requirments.txt`.
64
+
65
+ If you're on Linux, you also need the PortAudio system library for sounddevice to work, use `sudo apt install libportaudio2` to install it if you haven't already.
66
+
67
+ ---
68
+
69
+ ## Quick start
70
+
71
+ ```Shell
72
+ python random_looper.py rain.wav
73
+ ```
74
+
75
+ Play `rain.wav` forever with random loop points. Press **Ctrl+C** to stop.
76
+
77
+ ```Shell
78
+ python random_looper.py # (it asks you for the file path)
79
+ ```
80
+
81
+ ```Shell
82
+ python random_looper.py forest.flac -x "[(0,15),(60,75.5)]"
83
+ ```
84
+
85
+ Same, but never choose a loop point in 0-15s or 60-75.5s (for example, to avoid an intro or a loud bird call).
86
+
87
+ ```Shell
88
+ python random_looper.py ocean.ogg -e 10:00 -o ocean_10min.wav
89
+ ```
90
+
91
+ Write a 10-minute version to `ocean_10min.wav`, then play the original with random loops.
92
+
93
+ ```Shell
94
+ python random_looper.py wind.mp3 -e 1:00:00 --no-play
95
+ ```
96
+
97
+ Just write a one-hour file and don't play anything.
98
+
99
+ ---
100
+
101
+ ## Arguments
102
+
103
+ | Argument | Description | Default |
104
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
105
+ | `audio` | Path to the audio file. Optional: if you leave it out, the script prompts you for it. Quotes around the path are fine. | prompt |
106
+ | `-x`, `--exclude RANGES` | Time ranges, in **seconds**, that loop points may not land in. See [Exclusion ranges](#exclusion-ranges). | none |
107
+ | `-e`, `--extend LENGTH` | Also write an extended copy of the audio of exactly this length. Accepts seconds (`90`, `90.5`) or `mm:ss` / `h:mm:ss` (`10:00`, `1:30:00`). | off |
108
+ | `-o`, `--output PATH` | Where to write the extended file. The format comes from the extension (`.wav`, `.flac`, `.ogg`...). Only used with `--extend`. | `<name>_extended.wav` next to the input file |
109
+ | `--no-play` | Don't play anything. Useful with `--extend` to just make the file. | off (plays) |
110
+ | `--no-log` | Don't print the startup info line or the loop points while playing. | off (prints) |
111
+ | `-c`, `--crossfade SECONDS` | Length of the crossfade used to hide each jump. | `2.0` |
112
+ | `-m`, `--min-length SECONDS` | Minimum amount of audio **played before each jump** and **inside each loop** (the distance between the jump-to and jump-from points). Larger values mean longer stretches between jumps. | `10.0` |
113
+ | `--fade-out SECONDS` | Length of the fade-out at the very end of the extended file. Capped at a quarter of the extended length. Only used with `--extend`. | `4.0` |
114
+ | `--seed N` | Seed the random number generator so you get the same sequence of loop points every run. | random |
115
+
116
+ ### Automatic adjustments for short files
117
+
118
+ So that short files don't break the algorithm, the script clamps your settings:
119
+
120
+ - crossfade is limited to 1/8 of the file length (and is at least 0.05 s),
121
+ - min-length is limited to 1/3 of the file length, but never less than twice the crossfade.
122
+
123
+ If it changes your values, it prints the values it used.
124
+
125
+ ### Exclusion ranges
126
+
127
+ `-x` takes a Python-style list of `(start, end)` pairs in seconds:
128
+
129
+ ```Shell
130
+ -x "[(0, 15), (60, 75.5)]"
131
+ ```
132
+
133
+ - Wrap it in quotes so your shell/command prompt doesn't mangle the brackets and commas.
134
+ - A single pair works too: `-x "(0,15)"`.
135
+ - Overlapping ranges are merged, and a pair given backwards (`(20,10)`) is flipped.
136
+ - The ranges apply to **both** loop points: the point it jumps **from** and the point it jumps **to**. Neither will ever be picked inside an excluded range.
137
+ - Playback itself can still pass through excluded areas on its way. They are only off-limits as loop points.
138
+ - The crossfade window sits just *before* each loop point, so a crossfade can still overlap the edge of an excluded range. If that matters, make the range a little bigger.
139
+ - If your exclusions leave too little room for a loop (for example, you excluded almost the whole file), the script stops with an error telling you to shrink them or lower `--min-length` / `--crossfade`.
140
+
141
+ ---
142
+
143
+ ## How it works
144
+
145
+ 1. **Load.** The file is decoded into memory as 32-bit float audio.
146
+ 2. **Plan a jump.** For every loop the script picks a random pair of points, **B** (where it jumps *from*) and **A** (where it jumps *to*), with A earlier than B. The picks are uniform over all the allowed time (everything not excluded), and always satisfy:
147
+ - neither point is inside an excluded range,
148
+ - at least `--min-length` seconds play before the jump,
149
+ - at least `--min-length` seconds sit between A and B,
150
+ - A is at least one crossfade length into the file (there has to be audio before it to fade in),
151
+ - a valid next jump always exists, so the script can never paint itself into a corner.
152
+ 3. **Crossfade.** The audio just before B (the last `--crossfade` seconds) fades out while the audio just before A fades in, using an **equal-power** curve so the volume stays steady through the blend. When the crossfade finishes, playback continues from A as if nothing happened.
153
+ 4. **Repeat.** Playback continues to the next randomly chosen B, jumps back to a new random A, and so on forever. The first pass starts at the beginning of the file.
154
+
155
+ ### Playback
156
+
157
+ A background thread prepares audio in pieces (the normal stretch, then the crossfaded jump, and so on) and feeds a small queue. The sound card callback just copies from that queue, so the heavy lifting never happens in the audio thread. Each time a jump starts, a line like this is printed (unless `--no-log` is set):
158
+
159
+ ```
160
+ loop: 2:14.30 -> 0:45.20
161
+ ```
162
+
163
+ That means "jumped from 2:14.30 back to 0:45.20".
164
+
165
+ ### Extended file
166
+
167
+ With `--extend`, the same jump logic is run offline and streamed straight to disk, so even very long outputs don't need to fit in memory. The file is trimmed to exactly the length you asked for and ends with a fade-out (`--fade-out`). A progress percentage is shown while it renders. After rendering, the script plays the original unless you pass `--no-play`.
168
+
169
+ Output is 16-bit PCM for WAV by default. Samples are clipped to the valid range, since an equal-power crossfade of very similar loud audio can briefly exceed full scale.
170
+
171
+ ---
172
+
173
+ ## Tips for good results
174
+
175
+ - Pick **steady, texture-like** recordings with no obvious beginning, end or repeating pattern.
176
+ - If the source has a fade-in, fade-out, or a distinct event you don't want repeated or chopped, cover it with `--exclude`.
177
+ - Longer `--crossfade` (3-6 s) hides jumps better on very smooth sounds like drones. Shorter is fine for noisy textures like rain.
178
+ - A larger `--min-length` makes the loop feel slower and more natural; a smaller one means more frequent (but still smooth) jumps.
179
+ - Use `--seed` if you find a render you like and want to reproduce it.
180
+ - Very short source files give the randomizer little to work with, and repetition becomes easy to notice. Longer recordings (a minute or more) work best.
181
+
182
+ ---
183
+
184
+ ## Troubleshooting
185
+
186
+ | Problem | Fix |
187
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
188
+ | `Playback needs sounddevice` | `pip install sounddevice` |
189
+ | `File not found` | Check the path. Put quotes around paths with spaces. |
190
+ | `Could not read audio file` | The format may not be supported by your libsndfile build. Convert the file to WAV or FLAC. |
191
+ | `Could not parse --exclude` | Use the form `"[(10, 20), (45.5, 60)]"`, with the quotes. |
192
+ | `The exclusion ranges leave too little room...` | Shrink the exclusions, or lower `--min-length` / `--crossfade`. |
193
+ | Audible clicks or stutters in playback | Your machine may be too busy to keep the audio queue filled. Close other heavy programs. |
194
+ | Jumps sound obvious | The source is probably not stationary enough. See the warning at the top about music and distinct sounds. |
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "random-looper"
7
+ version = "0.1.0"
8
+ description = "Play an audio file forever with randomized, crossfaded loop points."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" } # change or remove if you prefer
12
+ authors = [{ name = "Your Name", email = "you@example.com" }]
13
+ keywords = ["audio", "looping", "ambient", "crossfade", "soundfile"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Topic :: Multimedia :: Sound/Audio",
17
+ "Environment :: Console",
18
+ ]
19
+ dependencies = [
20
+ "numpy>=1.21",
21
+ "soundfile>=0.12",
22
+ "sounddevice>=0.4.6",
23
+ ]
24
+
25
+ [project.optional-dependencies]
26
+ # For people who only ever use --no-play and don't want PortAudio installed:
27
+ render-only = ["numpy>=1.21", "soundfile>=0.12"]
28
+
29
+ [project.scripts]
30
+ random-looper = "random_looper.__main__:main"
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/Lorekeeper49/random-looper"
34
+
35
+ [tool.setuptools.packages.find]
36
+ where = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,6 @@
1
+ """random_looper — play audio with randomized, crossfaded loop points."""
2
+
3
+ from .__main__ import main
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["main", "__version__"]
@@ -0,0 +1,349 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ random_looper.py - endlessly play an audio file with randomized, crossfaded loop points.
4
+
5
+ Every loop, a new (loop_from, loop_to) pair is chosen at random. The jump is hidden by an
6
+ equal-power crossfade: the last moments before `loop_from` fade out while the moments just
7
+ before `loop_to` fade in, then playback carries on from `loop_to`.
8
+
9
+ Optionally:
10
+ * exclude time ranges (seconds) from being chosen as loop points
11
+ * render an extended copy of the audio to a desired length
12
+
13
+ Install:
14
+ pip install numpy soundfile sounddevice
15
+
16
+ Examples:
17
+ python random_looper.py song.wav
18
+ python random_looper.py song.flac -x "[(0,15),(60,75.5)]"
19
+ python random_looper.py song.ogg -e 10:00 -o song_10min.wav
20
+ python random_looper.py song.mp3 -e 1:00:00 --no-play
21
+ python random_looper.py (prompts for the file path)
22
+
23
+ Notes:
24
+ * Exclusion ranges are in seconds and apply to BOTH loop points (the jump-from point
25
+ and the jump-to point). The crossfade window sits just before each point.
26
+ * Reading mp3 needs libsndfile >= 1.1 (bundled with recent `soundfile` wheels).
27
+ """
28
+ import argparse
29
+ import ast
30
+ import queue
31
+ import random
32
+ import sys
33
+ import threading
34
+ import time
35
+ from dataclasses import dataclass
36
+ from pathlib import Path
37
+ from typing import List, Optional, Tuple
38
+
39
+ import numpy as np
40
+ import soundfile as sf
41
+
42
+ EPS = 1e-6
43
+ Span = Tuple[float, float]
44
+
45
+
46
+ # --------------------------------------------------------------------------- helpers
47
+ def parse_time(text) -> float:
48
+ """'90', '90.5', '1:30', '1:02:03' -> seconds."""
49
+ total = 0.0
50
+ for part in str(text).strip().split(":"):
51
+ total = total * 60 + float(part)
52
+ return total
53
+
54
+
55
+ def fmt(t: float) -> str:
56
+ m, s = divmod(t, 60)
57
+ return f"{int(m)}:{s:05.2f}"
58
+
59
+
60
+ def parse_exclusions(text: Optional[str]) -> List[Span]:
61
+ """Parse "[(start, end), (start, end)]" (seconds) into merged, sorted spans."""
62
+ if not text:
63
+ return []
64
+ try:
65
+ val = ast.literal_eval(text)
66
+ if (isinstance(val, tuple) and len(val) == 2
67
+ and all(isinstance(v, (int, float)) for v in val)):
68
+ val = [val] # a single (start, end) pair
69
+ spans = []
70
+ for a, b in val:
71
+ a, b = float(a), float(b)
72
+ spans.append((min(a, b), max(a, b)))
73
+ except (ValueError, SyntaxError, TypeError):
74
+ sys.exit('Could not parse --exclude. Use the form: "[(10, 20), (45.5, 60)]"')
75
+ spans.sort()
76
+ merged: List[Span] = []
77
+ for a, b in spans:
78
+ if merged and a <= merged[-1][1]:
79
+ merged[-1] = (merged[-1][0], max(merged[-1][1], b))
80
+ else:
81
+ merged.append((a, b))
82
+ return merged
83
+
84
+
85
+ def allowed_intervals(lo: float, hi: float, excl: List[Span]) -> List[Span]:
86
+ """[lo, hi] minus the excluded spans."""
87
+ spans = [(lo, hi)] if hi - lo > EPS else []
88
+ for ex_a, ex_b in excl:
89
+ out = []
90
+ for a, b in spans:
91
+ if ex_b <= a or ex_a >= b:
92
+ out.append((a, b))
93
+ continue
94
+ if ex_a > a:
95
+ out.append((a, ex_a))
96
+ if ex_b < b:
97
+ out.append((ex_b, b))
98
+ spans = [(a, b) for a, b in out if b - a > EPS]
99
+ return spans
100
+
101
+
102
+ def clip_spans(spans: List[Span], lo: float, hi: float) -> List[Span]:
103
+ out = []
104
+ for a, b in spans:
105
+ a2, b2 = max(a, lo), min(b, hi)
106
+ if b2 - a2 > EPS:
107
+ out.append((a2, b2))
108
+ return out
109
+
110
+
111
+ def sample_uniform(spans: List[Span], rng: random.Random) -> float:
112
+ total = sum(b - a for a, b in spans)
113
+ r = rng.uniform(0, total)
114
+ for a, b in spans:
115
+ if r <= b - a:
116
+ return a + r
117
+ r -= b - a
118
+ return spans[-1][1]
119
+
120
+
121
+ # --------------------------------------------------------------------------- loop logic
122
+ class LoopPlanner:
123
+ """
124
+ Picks random (loop_to, loop_from) pairs that always:
125
+ * avoid the excluded ranges,
126
+ * leave at least `min_len` seconds of playing before the jump and inside the loop,
127
+ * leave room for a crossfade before the loop_to point,
128
+ * never paint themselves into a corner (a valid next jump always exists).
129
+ """
130
+
131
+ def __init__(self, duration: float, crossfade: float, min_len: float,
132
+ excl: List[Span], rng: random.Random):
133
+ self.cf, self.min_len, self.rng = crossfade, min_len, rng
134
+ self.a_spans = allowed_intervals(crossfade, duration, excl) # valid loop_to points
135
+ self.b_spans = allowed_intervals(0.0, duration, excl) # valid loop_from points
136
+ if not self.a_spans or not self.b_spans:
137
+ raise ValueError("The exclusion ranges cover the whole file.")
138
+ self.a_min = self.a_spans[0][0]
139
+ self.b_max = self.b_spans[-1][1]
140
+ if self.b_max - self.a_min <= min_len + EPS:
141
+ raise ValueError(
142
+ "The exclusion ranges leave too little room for a loop "
143
+ f"(need more than {min_len:.1f}s between the earliest and latest allowed points). "
144
+ "Shrink the exclusions or lower --min-length / --crossfade.")
145
+
146
+ def next_pair(self, pos: float) -> Tuple[float, float]:
147
+ """Given current play position (s), return (loop_to A, loop_from B) with A < B."""
148
+ lo = max(pos + self.min_len, self.a_min + self.min_len)
149
+ b_spans = clip_spans(self.b_spans, lo, self.b_max)
150
+ if not b_spans:
151
+ raise RuntimeError("No valid loop point found.")
152
+ b = sample_uniform(b_spans, self.rng)
153
+ a_hi = min(b - self.min_len, self.b_max - self.min_len)
154
+ a_spans = clip_spans(self.a_spans, self.cf, a_hi)
155
+ if not a_spans:
156
+ raise RuntimeError("No valid loop point found.")
157
+ a = sample_uniform(a_spans, self.rng)
158
+ return a, b
159
+
160
+
161
+ @dataclass
162
+ class Piece:
163
+ data: np.ndarray # (frames, channels) float32
164
+ label: Optional[str] = None
165
+
166
+
167
+ def loop_stream(audio: np.ndarray, sr: int, planner: LoopPlanner, crossfade: float):
168
+ """Infinite generator of Pieces: body, crossfaded jump, body, crossfaded jump, ..."""
169
+ n = len(audio)
170
+ c = max(1, int(round(crossfade * sr)))
171
+ t = np.linspace(0.0, 1.0, c, dtype=np.float32)
172
+ fade_out = np.cos(t * np.pi / 2)[:, None] # equal-power curves
173
+ fade_in = np.sin(t * np.pi / 2)[:, None]
174
+ pos = 0
175
+ while True:
176
+ a_s, b_s = planner.next_pair(pos / sr)
177
+ a = max(c, int(round(a_s * sr)))
178
+ b = min(n, int(round(b_s * sr)))
179
+ yield Piece(audio[pos:b - c])
180
+ xfade = audio[b - c:b] * fade_out + audio[a - c:a] * fade_in
181
+ yield Piece(np.clip(xfade, -1.0, 1.0).astype(np.float32),
182
+ f"loop: {fmt(b / sr)} -> {fmt(a / sr)}")
183
+ pos = a
184
+
185
+
186
+ # --------------------------------------------------------------------------- extended file
187
+ def render_extended(audio, sr, planner, crossfade, target_sec, fade_sec, out_path: Path):
188
+ """Stream an extended version of the audio to disk (exactly target_sec long)."""
189
+ channels = audio.shape[1]
190
+ target = int(round(target_sec * sr))
191
+ fade_n = min(int(round(fade_sec * sr)), target // 4)
192
+ body_limit = target - fade_n
193
+ written = 0
194
+ tail: List[np.ndarray] = []
195
+ tail_len = 0
196
+ loops = 0
197
+ last_print = 0.0
198
+
199
+ with sf.SoundFile(str(out_path), "w", samplerate=sr, channels=channels) as out:
200
+ for piece in loop_stream(audio, sr, planner, crossfade):
201
+ d = piece.data
202
+ if piece.label:
203
+ loops += 1
204
+ if written < body_limit:
205
+ take = min(len(d), body_limit - written)
206
+ out.write(d[:take])
207
+ written += take
208
+ d = d[take:]
209
+ if len(d):
210
+ tail.append(d)
211
+ tail_len += len(d)
212
+ if written >= body_limit and tail_len >= fade_n:
213
+ break
214
+ now = time.time()
215
+ if now - last_print > 0.5:
216
+ pct = 100 * (written + tail_len) / target
217
+ print(f"\r rendering... {pct:5.1f}%", end="", flush=True)
218
+ last_print = now
219
+ if fade_n:
220
+ end = np.concatenate(tail)[:fade_n]
221
+ end = end * np.linspace(1.0, 0.0, fade_n, dtype=np.float32)[:, None]
222
+ out.write(end)
223
+ print(f"\r rendered {fmt(target_sec)} with {loops} loop jumps -> {out_path} ")
224
+
225
+
226
+ # --------------------------------------------------------------------------- playback
227
+ def play(audio, sr, planner, crossfade, log=True):
228
+ try:
229
+ import sounddevice as sd
230
+ except ImportError:
231
+ sys.exit("Playback needs sounddevice: pip install sounddevice")
232
+
233
+ channels = audio.shape[1]
234
+ q: "queue.Queue[Piece]" = queue.Queue(maxsize=4)
235
+ logq: "queue.Queue[str]" = queue.Queue()
236
+ stop = threading.Event()
237
+ state = {"cur": None, "off": 0}
238
+
239
+ def producer():
240
+ for piece in loop_stream(audio, sr, planner, crossfade):
241
+ while not stop.is_set():
242
+ try:
243
+ q.put(piece, timeout=0.2)
244
+ break
245
+ except queue.Full:
246
+ continue
247
+ if stop.is_set():
248
+ return
249
+
250
+ def callback(outdata, frames, time_info, status):
251
+ filled = 0
252
+ while filled < frames:
253
+ cur = state["cur"]
254
+ if cur is None or state["off"] >= len(cur):
255
+ try:
256
+ piece = q.get_nowait()
257
+ except queue.Empty:
258
+ outdata[filled:] = 0
259
+ return
260
+ state["cur"], state["off"] = piece.data, 0
261
+ if piece.label:
262
+ logq.put(piece.label)
263
+ continue
264
+ take = min(frames - filled, len(cur) - state["off"])
265
+ outdata[filled:filled + take] = cur[state["off"]:state["off"] + take]
266
+ state["off"] += take
267
+ filled += take
268
+
269
+ threading.Thread(target=producer, daemon=True).start()
270
+ print("Playing. Press Ctrl+C to stop.")
271
+ try:
272
+ with sd.OutputStream(samplerate=sr, channels=channels, dtype="float32",
273
+ callback=callback, blocksize=2048):
274
+ while True:
275
+ try:
276
+ msg = logq.get(timeout=0.2)
277
+ if log:
278
+ print(" ", msg)
279
+ except queue.Empty:
280
+ pass
281
+ except KeyboardInterrupt:
282
+ print("\nStopped.")
283
+ finally:
284
+ stop.set()
285
+
286
+
287
+ # --------------------------------------------------------------------------- main
288
+ def main():
289
+ p = argparse.ArgumentParser(description="Play audio with random crossfaded loop points.")
290
+ p.add_argument("audio", nargs="?", help="audio file (prompted for if omitted)")
291
+ p.add_argument("-x", "--exclude", metavar="RANGES",
292
+ help='seconds to exclude from loop points, e.g. "[(0,15),(60,75.5)]"')
293
+ p.add_argument("-e", "--extend", metavar="LENGTH",
294
+ help="also write an extended file of this length (seconds, or mm:ss / h:mm:ss)")
295
+ p.add_argument("-o", "--output", help="extended file path (default: <name>_extended.wav)")
296
+ p.add_argument("--no-play", action="store_true", help="skip playback (just write the file)")
297
+ p.add_argument("--no-log", action="store_true", help="skip logging loop points to stdout")
298
+ p.add_argument("-c", "--crossfade", type=float, default=2.0,
299
+ help="crossfade length in seconds (default 2)")
300
+ p.add_argument("-m", "--min-length", type=float, default=10.0,
301
+ help="minimum seconds of audio between jumps / inside a loop (default 10)")
302
+ p.add_argument("--fade-out", type=float, default=4.0,
303
+ help="fade-out at the end of the extended file, seconds (default 4)")
304
+ p.add_argument("--seed", type=int, help="random seed (for repeatable results)")
305
+ args = p.parse_args()
306
+
307
+ path = args.audio or input("Audio file path: ").strip().strip('"').strip("'")
308
+ if not Path(path).is_file():
309
+ sys.exit(f"File not found: {path}")
310
+
311
+ try:
312
+ audio, sr = sf.read(path, dtype="float32", always_2d=True)
313
+ except Exception as e: # noqa: BLE001
314
+ sys.exit(f"Could not read audio file: {e}")
315
+ duration = len(audio) / sr
316
+
317
+ # Keep settings sane for short files.
318
+ cf = max(0.05, min(args.crossfade, duration / 8))
319
+ min_len = max(2 * cf, min(args.min_length, duration / 3))
320
+ if (cf, min_len) != (args.crossfade, args.min_length):
321
+ print(f"Adjusted for file length: crossfade={cf:.2f}s, min-length={min_len:.2f}s")
322
+
323
+ excl = parse_exclusions(args.exclude)
324
+ rng = random.Random(args.seed)
325
+ try:
326
+ planner = LoopPlanner(duration, cf, min_len, excl, rng)
327
+ except ValueError as e:
328
+ sys.exit(str(e))
329
+
330
+ if not args.no_log:
331
+ print(f"Loaded {Path(path).name}: {fmt(duration)}, {sr} Hz, {audio.shape[1]} ch"
332
+ + (f", excluding {excl}" if excl else ""))
333
+
334
+ if args.extend:
335
+ target = parse_time(args.extend)
336
+ if target <= 0:
337
+ sys.exit("--extend must be positive.")
338
+ out_path = Path(args.output) if args.output else \
339
+ Path(path).with_name(Path(path).stem + "_extended.wav")
340
+ render_extended(audio, sr, planner, cf, target, args.fade_out, out_path)
341
+
342
+ if not args.no_play:
343
+ # fresh planner so playback isn't affected by the render
344
+ play(audio, sr, LoopPlanner(duration, cf, min_len, excl, rng), cf,
345
+ log=not args.no_log)
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: random-looper
3
+ Version: 0.1.0
4
+ Summary: Play an audio file forever with randomized, crossfaded loop points.
5
+ Author-email: Your Name <you@example.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/Lorekeeper49/random-looper
8
+ Keywords: audio,looping,ambient,crossfade,soundfile
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Topic :: Multimedia :: Sound/Audio
11
+ Classifier: Environment :: Console
12
+ Requires-Python: >=3.8
13
+ Description-Content-Type: text/markdown
14
+ License-File: LICENSE
15
+ Requires-Dist: numpy>=1.21
16
+ Requires-Dist: soundfile>=0.12
17
+ Requires-Dist: sounddevice>=0.4.6
18
+ Provides-Extra: render-only
19
+ Requires-Dist: numpy>=1.21; extra == "render-only"
20
+ Requires-Dist: soundfile>=0.12; extra == "render-only"
21
+ Dynamic: license-file
22
+
23
+ # Random Looper
24
+
25
+ Play an audio file forever with **randomized, crossfaded loop points**, and optionally render an extended copy of it to any length you like.
26
+
27
+ Every time the audio loops, the script picks a brand-new random "jump from" point and "jump to" point, then hides the jump with a crossfade. Because the points change on every loop, the result never repeats in the same way twice.
28
+
29
+ > ## ⚠️ Read this first: this is for atmosphere, not music
30
+ >
31
+ > This kind of looping only really works for **ambient / atmospheric audio**: rain, wind, ocean waves, forest or jungle ambience, a crackling fire, room tone, crowd murmur, an engine hum, a synth drone, and similar steady textures.
32
+ >
33
+ > It will **not** work well on **music**. Music has rhythm, melody, harmony and structure. Jumping between two random points means:
34
+ >
35
+ > - the beat lands in the wrong place, so the tempo feels like it stumbles,
36
+ > - the chords or key change abruptly or clash,
37
+ > - phrases, lyrics and builds get cut off or repeated mid-thought,
38
+ > - a crossfade between two unrelated musical passages just sounds like two songs playing over each other (muddy, phasing, "mashup" sound).
39
+ >
40
+ > Atmosphere survives this because it has no beat, melody or story. Any 10-second slice sounds much like any other, so a smooth blend between two random slices is nearly invisible.
41
+ >
42
+ > Even with atmosphere, **distinct one-off sounds** (a single bird call, a thunderclap, a door slam, a voice) can end up cut in half, doubled, or repeated suspiciously often. Use `--exclude` (below) to keep loop points away from those moments.
43
+
44
+ ---
45
+
46
+ ## Install
47
+
48
+ From PyPI:
49
+
50
+ ```Shell
51
+ pip install random-looper
52
+ ```
53
+
54
+ From this repo (editable, for development):
55
+
56
+ ```Shell
57
+ pip install -e .
58
+ ```
59
+
60
+ Once installed you get a `random-looper` command:
61
+
62
+ ```Shell
63
+ random-looper rain.wav
64
+ random-looper ocean.ogg -e 10:00 -o ocean_10min.wav
65
+ ```
66
+
67
+ Or run it as a module:
68
+
69
+ ```Shell
70
+ python -m random_looper rain.wav
71
+ ```
72
+
73
+ ## Manual Install
74
+
75
+ Requires Python 3.8+.
76
+
77
+ ```Shell
78
+ pip install numpy soundfile sounddevice
79
+ ```
80
+
81
+ - `numpy` and `soundfile` are needed for everything.
82
+ - `sounddevice` is only needed for live playback. If you only use `--no-play`, you can skip it.
83
+ - WAV, FLAC and OGG work out of the box. MP3 needs libsndfile 1.1 or newer, which recent `soundfile` wheels include.
84
+
85
+ Or you can install from `requirements.txt` using `pip install -r requirments.txt`.
86
+
87
+ If you're on Linux, you also need the PortAudio system library for sounddevice to work, use `sudo apt install libportaudio2` to install it if you haven't already.
88
+
89
+ ---
90
+
91
+ ## Quick start
92
+
93
+ ```Shell
94
+ python random_looper.py rain.wav
95
+ ```
96
+
97
+ Play `rain.wav` forever with random loop points. Press **Ctrl+C** to stop.
98
+
99
+ ```Shell
100
+ python random_looper.py # (it asks you for the file path)
101
+ ```
102
+
103
+ ```Shell
104
+ python random_looper.py forest.flac -x "[(0,15),(60,75.5)]"
105
+ ```
106
+
107
+ Same, but never choose a loop point in 0-15s or 60-75.5s (for example, to avoid an intro or a loud bird call).
108
+
109
+ ```Shell
110
+ python random_looper.py ocean.ogg -e 10:00 -o ocean_10min.wav
111
+ ```
112
+
113
+ Write a 10-minute version to `ocean_10min.wav`, then play the original with random loops.
114
+
115
+ ```Shell
116
+ python random_looper.py wind.mp3 -e 1:00:00 --no-play
117
+ ```
118
+
119
+ Just write a one-hour file and don't play anything.
120
+
121
+ ---
122
+
123
+ ## Arguments
124
+
125
+ | Argument | Description | Default |
126
+ | -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- |
127
+ | `audio` | Path to the audio file. Optional: if you leave it out, the script prompts you for it. Quotes around the path are fine. | prompt |
128
+ | `-x`, `--exclude RANGES` | Time ranges, in **seconds**, that loop points may not land in. See [Exclusion ranges](#exclusion-ranges). | none |
129
+ | `-e`, `--extend LENGTH` | Also write an extended copy of the audio of exactly this length. Accepts seconds (`90`, `90.5`) or `mm:ss` / `h:mm:ss` (`10:00`, `1:30:00`). | off |
130
+ | `-o`, `--output PATH` | Where to write the extended file. The format comes from the extension (`.wav`, `.flac`, `.ogg`...). Only used with `--extend`. | `<name>_extended.wav` next to the input file |
131
+ | `--no-play` | Don't play anything. Useful with `--extend` to just make the file. | off (plays) |
132
+ | `--no-log` | Don't print the startup info line or the loop points while playing. | off (prints) |
133
+ | `-c`, `--crossfade SECONDS` | Length of the crossfade used to hide each jump. | `2.0` |
134
+ | `-m`, `--min-length SECONDS` | Minimum amount of audio **played before each jump** and **inside each loop** (the distance between the jump-to and jump-from points). Larger values mean longer stretches between jumps. | `10.0` |
135
+ | `--fade-out SECONDS` | Length of the fade-out at the very end of the extended file. Capped at a quarter of the extended length. Only used with `--extend`. | `4.0` |
136
+ | `--seed N` | Seed the random number generator so you get the same sequence of loop points every run. | random |
137
+
138
+ ### Automatic adjustments for short files
139
+
140
+ So that short files don't break the algorithm, the script clamps your settings:
141
+
142
+ - crossfade is limited to 1/8 of the file length (and is at least 0.05 s),
143
+ - min-length is limited to 1/3 of the file length, but never less than twice the crossfade.
144
+
145
+ If it changes your values, it prints the values it used.
146
+
147
+ ### Exclusion ranges
148
+
149
+ `-x` takes a Python-style list of `(start, end)` pairs in seconds:
150
+
151
+ ```Shell
152
+ -x "[(0, 15), (60, 75.5)]"
153
+ ```
154
+
155
+ - Wrap it in quotes so your shell/command prompt doesn't mangle the brackets and commas.
156
+ - A single pair works too: `-x "(0,15)"`.
157
+ - Overlapping ranges are merged, and a pair given backwards (`(20,10)`) is flipped.
158
+ - The ranges apply to **both** loop points: the point it jumps **from** and the point it jumps **to**. Neither will ever be picked inside an excluded range.
159
+ - Playback itself can still pass through excluded areas on its way. They are only off-limits as loop points.
160
+ - The crossfade window sits just *before* each loop point, so a crossfade can still overlap the edge of an excluded range. If that matters, make the range a little bigger.
161
+ - If your exclusions leave too little room for a loop (for example, you excluded almost the whole file), the script stops with an error telling you to shrink them or lower `--min-length` / `--crossfade`.
162
+
163
+ ---
164
+
165
+ ## How it works
166
+
167
+ 1. **Load.** The file is decoded into memory as 32-bit float audio.
168
+ 2. **Plan a jump.** For every loop the script picks a random pair of points, **B** (where it jumps *from*) and **A** (where it jumps *to*), with A earlier than B. The picks are uniform over all the allowed time (everything not excluded), and always satisfy:
169
+ - neither point is inside an excluded range,
170
+ - at least `--min-length` seconds play before the jump,
171
+ - at least `--min-length` seconds sit between A and B,
172
+ - A is at least one crossfade length into the file (there has to be audio before it to fade in),
173
+ - a valid next jump always exists, so the script can never paint itself into a corner.
174
+ 3. **Crossfade.** The audio just before B (the last `--crossfade` seconds) fades out while the audio just before A fades in, using an **equal-power** curve so the volume stays steady through the blend. When the crossfade finishes, playback continues from A as if nothing happened.
175
+ 4. **Repeat.** Playback continues to the next randomly chosen B, jumps back to a new random A, and so on forever. The first pass starts at the beginning of the file.
176
+
177
+ ### Playback
178
+
179
+ A background thread prepares audio in pieces (the normal stretch, then the crossfaded jump, and so on) and feeds a small queue. The sound card callback just copies from that queue, so the heavy lifting never happens in the audio thread. Each time a jump starts, a line like this is printed (unless `--no-log` is set):
180
+
181
+ ```
182
+ loop: 2:14.30 -> 0:45.20
183
+ ```
184
+
185
+ That means "jumped from 2:14.30 back to 0:45.20".
186
+
187
+ ### Extended file
188
+
189
+ With `--extend`, the same jump logic is run offline and streamed straight to disk, so even very long outputs don't need to fit in memory. The file is trimmed to exactly the length you asked for and ends with a fade-out (`--fade-out`). A progress percentage is shown while it renders. After rendering, the script plays the original unless you pass `--no-play`.
190
+
191
+ Output is 16-bit PCM for WAV by default. Samples are clipped to the valid range, since an equal-power crossfade of very similar loud audio can briefly exceed full scale.
192
+
193
+ ---
194
+
195
+ ## Tips for good results
196
+
197
+ - Pick **steady, texture-like** recordings with no obvious beginning, end or repeating pattern.
198
+ - If the source has a fade-in, fade-out, or a distinct event you don't want repeated or chopped, cover it with `--exclude`.
199
+ - Longer `--crossfade` (3-6 s) hides jumps better on very smooth sounds like drones. Shorter is fine for noisy textures like rain.
200
+ - A larger `--min-length` makes the loop feel slower and more natural; a smaller one means more frequent (but still smooth) jumps.
201
+ - Use `--seed` if you find a render you like and want to reproduce it.
202
+ - Very short source files give the randomizer little to work with, and repetition becomes easy to notice. Longer recordings (a minute or more) work best.
203
+
204
+ ---
205
+
206
+ ## Troubleshooting
207
+
208
+ | Problem | Fix |
209
+ | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
210
+ | `Playback needs sounddevice` | `pip install sounddevice` |
211
+ | `File not found` | Check the path. Put quotes around paths with spaces. |
212
+ | `Could not read audio file` | The format may not be supported by your libsndfile build. Convert the file to WAV or FLAC. |
213
+ | `Could not parse --exclude` | Use the form `"[(10, 20), (45.5, 60)]"`, with the quotes. |
214
+ | `The exclusion ranges leave too little room...` | Shrink the exclusions, or lower `--min-length` / `--crossfade`. |
215
+ | Audible clicks or stutters in playback | Your machine may be too busy to keep the audio queue filled. Close other heavy programs. |
216
+ | Jumps sound obvious | The source is probably not stationary enough. See the warning at the top about music and distinct sounds. |
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/random_looper/__init__.py
5
+ src/random_looper/__main__.py
6
+ src/random_looper.egg-info/PKG-INFO
7
+ src/random_looper.egg-info/SOURCES.txt
8
+ src/random_looper.egg-info/dependency_links.txt
9
+ src/random_looper.egg-info/entry_points.txt
10
+ src/random_looper.egg-info/requires.txt
11
+ src/random_looper.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ random-looper = random_looper.__main__:main
@@ -0,0 +1,7 @@
1
+ numpy>=1.21
2
+ soundfile>=0.12
3
+ sounddevice>=0.4.6
4
+
5
+ [render-only]
6
+ numpy>=1.21
7
+ soundfile>=0.12
@@ -0,0 +1 @@
1
+ random_looper