├── icon.ico
├── requirements.txt
├── .gitignore
├── demo
├── Audio-Capture-App-v1.0.0_M34NcRNW4H.png
└── Audio-Capture-App-v1.0.0_M34NcRNW4K.PNG
├── LICENSE.md
├── README.md
└── app.py
/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cfrBernard/audio-capture-app/HEAD/icon.ico
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cfrBernard/audio-capture-app/HEAD/requirements.txt
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | *.pyc
3 | *.wav
4 | .vscode/
5 | .env
6 | venv/
7 | config.txt
8 | build/
9 | dist/
10 | app.spec
--------------------------------------------------------------------------------
/demo/Audio-Capture-App-v1.0.0_M34NcRNW4H.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cfrBernard/audio-capture-app/HEAD/demo/Audio-Capture-App-v1.0.0_M34NcRNW4H.png
--------------------------------------------------------------------------------
/demo/Audio-Capture-App-v1.0.0_M34NcRNW4K.PNG:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cfrBernard/audio-capture-app/HEAD/demo/Audio-Capture-App-v1.0.0_M34NcRNW4K.PNG
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | # MIT License
2 |
3 | ### Copyright (c) 2024 C. Bernard
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Audio Capture Tool
2 |
3 | This tool was developed to help with **organizing**, **sorting**, and **capturing improvised audio recordings**. It allows users to listen to audio input from a selected device, visualize it in real-time with a spectrometer, and easily capture short audio snippets with user-defined filenames and durations. It also provides a convenient way to manage saved captures and configure the save directory.
4 |
5 | [**Download the latest version here**](https://github.com/cfrBernard/audio-capture-app/releases)
6 |
7 | [](https://github.com/cfrBernard/audio-capture-app/releases)
8 | [](./LICENSE.md)
9 |
10 | ## Features
11 |
12 | - **Device Selection**: Easily select an input audio device from a dropdown menu.
13 | - **Listening Control**: Start and stop audio input listening with a single click.
14 | - **Real-Time Visualization**: Display audio levels visually using a dynamic spectrometer.
15 | - **Audio Capture**:
16 | - Capture audio snippets with a custom filename.
17 | - Configure capture duration (1–300 seconds).
18 | - Avoid overwriting existing files with filename validation.
19 | - **Save Directory Configuration**:
20 | - Choose a directory for saving audio captures.
21 | - Save the configuration persistently across sessions.
22 | - **Error Handling**: Intuitive error messages and feedback for invalid input or missing configurations.
23 |
24 | ---
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 | ---
37 |
38 | ## 🛠️ Getting Started
39 |
40 | ### Prerequisites
41 |
42 | - **Python 3.8+**
43 | - **Required Libraries**:
44 | - `tkinter`: GUI framework (comes pre-installed with Python).
45 | - `sounddevice`: For audio input.
46 | - `numpy`: For numerical operations.
47 | - `wave`: To save audio files in WAV format.
48 |
49 | You can install the dependencies with the following command:
50 |
51 | ```bash
52 | pip install -r requirements.txt
53 | ```
54 |
55 | > Note: Using a .venv is highly recommended.
56 |
57 | ### Running the Application
58 |
59 | 1. Clone this repository or download the source code.
60 | 2. Open a terminal and navigate to the project directory.
61 | 3. Run the application using:
62 |
63 | ```bash
64 | python app.py
65 | ```
66 |
67 | ---
68 |
69 | ## How to Use
70 |
71 | ### Selecting an Input Device
72 |
73 | - Choose an input device from the dropdown list at the top of the application window.
74 | - Only devices with input capabilities are listed.
75 | - Press **Start Listening** to begin monitoring the audio input.
76 |
77 | ### Capturing Audio
78 |
79 | 1. Click the **Capture** button to open the capture configuration dialog.
80 | 2. Enter a filename (without the extension). If no filename is provided, the app generates one automatically.
81 | 3. Specify the duration of the capture (1–300 seconds).
82 | 4. The file is saved in the configured save directory.
83 |
84 | ### Managing the Save Directory
85 |
86 | - The current save directory is displayed on the **Save Directory** button.
87 | - Click the button to change the directory.
88 | - The selection is saved persistently in a `config.txt` file.
89 |
90 | ### Real-Time Visualization
91 |
92 | - A spectrometer displays the audio input levels in real-time.
93 |
94 | ---
95 |
96 | ## File Structure
97 |
98 | ```bash
99 | .
100 | ├── app.py # Main application code
101 | ├── config.txt # Configuration file for save directory
102 | ├── requirements.txt # List of dependencies
103 | ├── LICENSE.md # MIT License
104 | └── README.md # Documentation for the project
105 | ```
106 |
107 | ---
108 |
109 | ## ⚠️ Troubleshooting
110 |
111 | ### No Input Devices Listed:
112 |
113 | - Ensure that your audio input device (e.g., microphone) is connected and enabled.
114 | - Restart the application.
115 |
116 | ### Save Directory Issues:
117 |
118 | - The app defaults to the current directory if no directory is selected.
119 | - Ensure you have write permissions for the chosen directory.
120 |
121 | ---
122 |
123 | ## 🔮 Future Enhancements
124 |
125 | - Add MIDI support (for input & export).
126 | - Add support for stereo audio input.
127 | - Enhance the spectrometer with frequency domain analysis.
128 | - Implement additional audio file formats (e.g., MP3).
129 | - Add playback functionality for captured files directly within the app.
130 |
131 | ## License
132 |
133 | This project is licensed under the MIT License. See the [LICENSE](./LICENSE.md) file for details.
134 |
135 | ---
136 |
137 | > **Note**: A MacOS version will be released in the future.
138 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import tkinter as tk
2 | from tkinter import ttk, simpledialog, messagebox, filedialog
3 | import sounddevice as sd
4 | import numpy as np
5 | import wave
6 | import os
7 | from datetime import datetime
8 |
9 | # Filepath to the config file
10 | config_file_path = 'config.txt'
11 |
12 | # Default save directory (the current directory of the application)
13 | default_save_directory = os.getcwd()
14 |
15 | # Function to read the config file and load the save directory
16 | def load_config():
17 | global save_directory
18 | save_directory = default_save_directory # Set default directory at the beginning
19 |
20 | if os.path.exists(config_file_path):
21 | with open(config_file_path, 'r') as f:
22 | lines = f.readlines()
23 | for line in lines:
24 | if line.startswith('save_directory='):
25 | save_directory = line.strip().split('=')[1]
26 | break
27 |
28 | # Update the label to display the correct path
29 | save_directory_button.config(text=f"Save Directory: {save_directory}")
30 |
31 | # Function to write the save directory to the config file
32 | def save_config():
33 | with open(config_file_path, 'w') as f:
34 | f.write(f"save_directory={save_directory}\n")
35 |
36 | # Function to retrieve valid input devices
37 | def get_input_devices():
38 | devices = sd.query_devices()
39 | input_devices = []
40 |
41 | # Add only valid devices
42 | for device in devices:
43 | if device['max_input_channels'] > 0 and "Microsoft Sound Mapper - Input" not in device['name']:
44 | input_devices.append(device['name']) # Add only valid devices
45 | return input_devices
46 |
47 | # Global variables
48 | listening = False
49 | circular_buffer = None
50 | stream = None
51 | save_directory = None
52 |
53 | # Function to log messages
54 | def log_message(message):
55 | log_text.config(state=tk.NORMAL) # Enable text box to insert text
56 | log_text.insert(tk.END, message + '\n')
57 | log_text.yview(tk.END) # Scroll to the latest message
58 | log_text.config(state=tk.DISABLED) # Disable text box after insertion
59 |
60 | # Function to start listening
61 | def start_listening():
62 | global listening, circular_buffer, stream
63 | # Check if a device is selected
64 | if not device_combobox.get():
65 | log_message("Error: Please select an input device.")
66 | return
67 |
68 | try:
69 | listening = True
70 | pastille_canvas.itemconfig(pastille, fill="red") # Visual indicator (red dot)
71 | selected_device = device_combobox.get()
72 | device_index = next(i for i, d in enumerate(sd.query_devices()) if d['name'] == selected_device)
73 | sd.default.device = device_index
74 | sd.default.samplerate = 44100
75 | sd.default.channels = 1
76 |
77 | buffer_size = int(44100 * 300) # 300 seconds
78 | circular_buffer = np.zeros(buffer_size, dtype=np.float32)
79 |
80 | def audio_callback(indata, frames, time, status):
81 | global circular_buffer
82 | if status:
83 | log_message(f"Status: {status}")
84 | # Add new data to the circular buffer
85 | circular_buffer[:-frames] = circular_buffer[frames:]
86 | circular_buffer[-frames:] = indata[:, 0]
87 |
88 | # Start the audio stream
89 | stream = sd.InputStream(callback=audio_callback)
90 | stream.start()
91 | log_message("Listening has started.")
92 | except Exception as e:
93 | log_message(f"Error starting listening: {e}")
94 |
95 | # Function to stop listening
96 | def stop_listening():
97 | global listening, stream
98 | if listening:
99 | listening = False
100 | pastille_canvas.itemconfig(pastille, fill="gray") # Reset visual indicator (gray dot)
101 | if stream is not None:
102 | stream.stop() # Stop the stream
103 | stream.close() # Close the stream
104 | log_message("Listening has stopped.")
105 | else:
106 | log_message("No listening session is currently active.")
107 |
108 | # Function to select the directory for saving captures
109 | def select_save_directory():
110 | global save_directory
111 | directory = filedialog.askdirectory(title="Select Folder for Captures")
112 | if directory: # Only update if the user selects a directory
113 | save_directory = directory
114 | save_directory_button.config(text=f"Save Directory: {save_directory}")
115 | log_message(f"Capture files will be saved to: {save_directory}")
116 | save_config() # Save the new directory to the config file
117 |
118 | def capture_audio():
119 | global listening, circular_buffer, stream, save_directory
120 | if not listening:
121 | log_message("Error: Listening has not started.")
122 | return
123 |
124 | # Pause listening while configuring the capture
125 | log_message("Pausing listening for capture configuration...")
126 | stop_listening()
127 |
128 | # Prompt for the filename
129 | filename = simpledialog.askstring("File Name", "Enter the file name:",
130 | initialvalue=f"capture_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
131 | if not filename: # Cancel pressed or empty input
132 | log_message("Capture canceled by the user.")
133 | start_listening()
134 | return
135 |
136 | # Ensure the extension
137 | if not filename.lower().endswith('.wav'):
138 | filename += '.wav'
139 |
140 | # Check save directory
141 | if not save_directory:
142 | save_directory = default_save_directory
143 | log_message(f"No directory selected. Using default directory: {save_directory}")
144 |
145 | # Generate the full file path
146 | file_path = os.path.join(save_directory, filename)
147 |
148 | # Check for overwrites
149 | if os.path.exists(file_path):
150 | overwrite = messagebox.askyesno("File exists", f"The file {filename} already exists. Do you want to overwrite it?")
151 | if not overwrite:
152 | log_message("Capture aborted: file not overwritten.")
153 | start_listening()
154 | return
155 |
156 | # Prompt for duration
157 | duration = simpledialog.askinteger("Capture Duration", "Capture duration (in seconds, 1-300):",
158 | minvalue=1, maxvalue=300)
159 | if not duration: # Cancel pressed
160 | log_message("Capture canceled.")
161 | start_listening()
162 | return
163 |
164 | # Retrieve and save data
165 | try:
166 | sample_rate = 44100
167 | num_samples = duration * sample_rate
168 | captured_audio = circular_buffer[-num_samples:]
169 |
170 | with wave.open(file_path, 'wb') as wf:
171 | wf.setnchannels(1)
172 | wf.setsampwidth(2)
173 | wf.setframerate(sample_rate)
174 | wf.writeframes((captured_audio * 32767).astype(np.int16))
175 | log_message(f"Capture saved as {file_path}")
176 | except Exception as e:
177 | log_message(f"Error saving the recording: {e}")
178 |
179 | # Resume listening after capture dialog closes
180 | log_message("Resuming listening...")
181 | start_listening()
182 |
183 |
184 | # Function to update the visual spectrum (simple amplitude-based display)
185 | def update_spectrometer():
186 | global circular_buffer
187 | if listening and circular_buffer is not None:
188 | # Get the latest data from the circular buffer
189 | data = circular_buffer[-2048:] # Take the last 2048 samples for better sensitivity
190 |
191 | # Calculate the amplitude (root mean square of the signal)
192 | amplitude = np.sqrt(np.mean(data**2))
193 |
194 | # Update the spectrometer bars based on the amplitude
195 | # Adjust the threshold for each bar based on the intensity
196 | for i in range(len(spectrometer_bars)):
197 | # More sensitive display
198 | if amplitude > (i + 1) * 0.005: # Lower threshold for even greater sensitivity
199 | spectrometer_bars[i].config(height=10) # Show bar (height 10)
200 | else:
201 | spectrometer_bars[i].config(height=1) # Hide bar (height 1)
202 |
203 | # Call the function again after 50ms to update the spectrometer in real-time
204 | root.after(50, update_spectrometer)
205 |
206 | # Setup the main window
207 | root = tk.Tk()
208 | root.title("Audio capture app")
209 | root.geometry("350x500")
210 |
211 | # Title label
212 | label = tk.Label(root, text="Select an Audio Input Device", font=("Arial", 13))
213 | label.pack(pady=10)
214 |
215 | # Frame for input device selection
216 | device_frame = tk.Frame(root)
217 | device_frame.pack(pady=10)
218 |
219 | # ComboBox for selecting the device
220 | device_combobox = ttk.Combobox(device_frame, width=35)
221 | device_combobox.grid(row=0, column=0, padx=10)
222 |
223 | # Initialize the list of devices
224 | input_devices = get_input_devices()
225 | device_combobox['values'] = input_devices
226 | if input_devices:
227 | device_combobox.current(0)
228 |
229 | # Frame for listening control
230 | control_frame = tk.Frame(root)
231 | control_frame.pack(pady=20)
232 |
233 | # Start Listening button
234 | start_button = tk.Button(control_frame, text="Start Listening", command=start_listening)
235 | start_button.grid(row=0, column=0, padx=10)
236 |
237 | # Canvas for visual indicator (red dot)
238 | pastille_canvas = tk.Canvas(control_frame, width=20, height=20)
239 | pastille = pastille_canvas.create_oval(5, 5, 15, 15, fill="gray")
240 | pastille_canvas.grid(row=0, column=1, padx=10)
241 |
242 | # Stop Listening button
243 | stop_button = tk.Button(control_frame, text="Stop Listening", command=stop_listening)
244 | stop_button.grid(row=0, column=2, padx=10)
245 |
246 | # Spectrometer container frame with fixed dimensions
247 | spectrometer_container = tk.Frame(root, height=25, width=350,)
248 | spectrometer_container.pack_propagate(False) # Prevents resizing
249 | spectrometer_container.pack(pady=20)
250 |
251 | # Inner canvas for the spectrometer
252 | spectrometer_canvas = tk.Canvas(spectrometer_container, height=25, width=350)
253 | spectrometer_canvas.pack(fill=tk.BOTH, expand=True)
254 |
255 | # Inner frame for spectrometer bars
256 | spectrometer_frame = tk.Frame(spectrometer_canvas, height=25, width=350)
257 | spectrometer_frame.pack_propagate(False) # Prevent the frame from resizing to its children
258 | spectrometer_canvas.create_window((0, 0), window=spectrometer_frame, anchor="nw")
259 |
260 | # Define the size and layout for bars
261 | spectrometer_bars = []
262 | num_bars = 80
263 | bar_width = 5
264 | bar_spacing = 1
265 | bar_height = 1 # Minimum height
266 |
267 | for i in range(num_bars):
268 | bar = tk.Canvas(
269 | spectrometer_frame,
270 | width=bar_width,
271 | height=bar_height,
272 | bg="black",
273 | highlightthickness=0 # Remove border
274 | )
275 | bar.place(x=i * (bar_width + bar_spacing), y=0) # Position bar
276 | spectrometer_bars.append(bar)
277 |
278 | # Add capture button and save directory button
279 | capture_button = tk.Button(root, text="Capture", command=capture_audio)
280 | capture_button.pack(pady=0)
281 |
282 | save_directory_button = tk.Button(root, text="Save Directory: ", command=select_save_directory)
283 | save_directory_button.pack(pady=30)
284 |
285 | # Log window for feedback
286 | log_text = tk.Text(root, height=10, width=60, state=tk.DISABLED)
287 | log_text.pack(pady=10)
288 |
289 | # Initialize spectrometer update loop
290 | update_spectrometer()
291 |
292 | # Load the configuration file
293 | load_config()
294 |
295 | # Start the Tkinter main loop
296 | root.mainloop()
297 |
--------------------------------------------------------------------------------