Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,9 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/

# vscode
.vscode/

*.exe

.idea/*
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ dependencies = [
"PyQt5",
"pydub",
"moviepy",
"proglog"
"proglog",
"numpy",
"sounddevice"
]

[project.urls]
Expand Down
116 changes: 116 additions & 0 deletions src/binary_waterfall/buffer_player.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import numpy as np
import sounddevice as sd

UINT8_MIDPOINT = 1 << 7
INT16_SIGN_BIT = 1 << 15
INT24_SIGN_BIT = 1 << 23
INT24_MODULUS = 1 << 24
INT32_SIGN_BIT = 1 << 31

def pcm_bytes_to_float32(raw, sample_bytes, num_channels):
raw = bytes(raw)
bytes_per_frame = sample_bytes * num_channels
if bytes_per_frame <= 0 or len(raw) < bytes_per_frame:
return np.zeros((0, max(num_channels, 1)), dtype=np.float32)

# throw tail away if not a multiple of the frame size
usable = len(raw) - (len(raw) % bytes_per_frame)
raw = raw[:usable]
frames = usable // bytes_per_frame

# convert int to float in range of -1.0 to 1.0
if sample_bytes == 1:
samples = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
samples = (samples - UINT8_MIDPOINT) / float(UINT8_MIDPOINT)
elif sample_bytes == 2:
samples = np.frombuffer(raw, dtype="<i2").astype(np.float32) / float(INT16_SIGN_BIT)
elif sample_bytes == 3:
# due to lack of int24 / <i3 we build samples by hand
packed = np.frombuffer(raw, dtype=np.uint8).reshape(-1, 3)
values = (
packed[:, 0].astype(np.int32)
| (packed[:, 1].astype(np.int32) << 8)
| (packed[:, 2].astype(np.int32) << 16)
)
values = np.where(values >= INT24_SIGN_BIT, values - INT24_MODULUS, values)
samples = values.astype(np.float32) / float(INT24_SIGN_BIT)
elif sample_bytes == 4:
samples = np.frombuffer(raw, dtype="<i4").astype(np.float32) / float(INT32_SIGN_BIT)
else:
raise ValueError(f"Unsupported sample width: {sample_bytes} bytes")

np.clip(samples, -1.0, 1.0, out=samples)
return samples.reshape(frames, num_channels)

class BufferPlayer:
def __init__(self, obtain_data_cb, samplerate=32000, channels=1, blocksize=1024):
self.obtain_data = obtain_data_cb
self.is_playing = False
self._gain = 1.0
self.stream = sd.OutputStream(
samplerate=samplerate,
channels=channels,
dtype="float32",
callback=self._callback,
blocksize=blocksize,
)

def _callback(self, outdata, frames, time, status):
outdata.fill(0)
data = self.obtain_data(frames)

if data is None:
return

if data.ndim == 1:
data = data.reshape(-1, 1)

if data.shape != outdata.shape:
raise ValueError(
f"Audio chunk shape {data.shape} does not match stream {outdata.shape}"
)

if self._gain != 1.0:
data = data * np.float32(self._gain)

np.clip(data, -1.0, 1.0, out=data)
outdata[:] = data

def setVolume(self, volume):
self._gain = volume / 100

def update_stream_safe(self, sample_rate, num_channels, block_size=None):
if block_size is None:
block_size = self.stream.blocksize

was_playing = self.is_playing
self.stop()
self.stream.close()
self.stream = sd.OutputStream(
samplerate=sample_rate,
channels=num_channels,
dtype="float32",
callback=self._callback,
blocksize=block_size,
)

if was_playing:
self.start()

def start(self):
if self.is_playing or self.stream is None:
return
self.stream.start()
self.is_playing = True

def stop(self):
if self.stream is None or not self.is_playing:
return
self.stream.stop()
self.is_playing = False

def close(self):
self.stop()
if self.stream is not None:
self.stream.close()
self.stream = None
122 changes: 51 additions & 71 deletions src/binary_waterfall/generators.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import os
import shutil
import tempfile
import math
import threading
import wave
from PIL import Image, ImageOps
import pydub
Expand Down Expand Up @@ -31,7 +31,6 @@ def __init__(self,
playhead_visible=constants.DEFAULTS["playhead_visible"]
):
# Initialize class variables
self.audio_length_ms = None
self.volume = None
self.sample_rate = None
self.sample_bytes = None
Expand All @@ -40,20 +39,16 @@ def __init__(self,
self.color_bytes = None
self.unused_color_bytes = None
self.used_color_bytes = None
self.filename = None
self.height = None
self.width = None
self.dim = None
self.total_bytes = None
self.file = None
self.audio_filename = None
self.flip_v = None
self.flip_h = None
self.alignment = None
self.playhead_visible = None

# Make the temp dir for the class instance
self.temp_dir = tempfile.mkdtemp()
self._file_lock = threading.Lock()

# Set the filename in
self.set_filename(filename=filename)
Expand Down Expand Up @@ -89,38 +84,22 @@ def close_file(self):
if self.file is not None:
self.file.close()
self.file = None
self.filename = None

def set_filename(self, filename):
# Delete current audio file if it exists
self.delete_audio()

if filename is None:
# Reset all vars and close the file pointer
self.close_file()
self.total_bytes = None
self.audio_filename = None
return

if not os.path.isfile(filename):
raise FileNotFoundError(f"File not found: \"{filename}\"")

self.filename = os.path.realpath(filename)
if self.file is not None:
self.file.close()
self.file = None

# Open file
self.file = open(self.filename, "rb")

# Get total number of bytes
self.file.seek(0, os.SEEK_END)
self.total_bytes = self.file.tell()
self.file.seek(0)

# Compute audio file name
file_path, file_main_name = os.path.split(self.filename)
self.audio_filename = os.path.join(
self.temp_dir,
file_main_name + os.path.extsep + "wav"
)
self.file = open(os.path.realpath(filename), "rb")

def set_dims(self, width, height):
if width < 4:
Expand Down Expand Up @@ -308,72 +287,75 @@ def set_audio_settings(self,
self.sample_rate = sample_rate
self.volume = volume

# Re-compute audio file
self.compute_audio()

def delete_audio(self):
if self.audio_filename is None:
# Do nothing
return
try:
os.remove(self.audio_filename)
except FileNotFoundError:
pass

def get_audio_length(self):
audio_length = pydub.AudioSegment.from_file(self.audio_filename).duration_seconds
audio_length_ms = math.ceil(audio_length * 1000)
def get_audio_length_ms(self):
total_bytes = self.get_total_bytes()
if self.file is None or not total_bytes or not self.sample_rate:
return 0

return audio_length_ms
bytes_per_frame = self.sample_bytes * self.num_channels
frames = total_bytes // bytes_per_frame
return math.ceil(frames * 1000 / self.sample_rate)

def compute_audio(self):
if self.filename is None:
# If there is no file set, reset the vars
self.audio_length_ms = None
return
def write_wav(self, filename):
if self.file is None:
raise ValueError("No file is open")

# Delete current file if it exists
self.delete_audio()
helpers.make_file_path(filename)

# Compute the new file (full volume)
with wave.open(self.audio_filename, "wb") as f:
with wave.open(filename, "wb") as f:
f.setnchannels(self.num_channels)
f.setsampwidth(self.sample_bytes)
f.setframerate(self.sample_rate)
self.file.seek(0)
for chunk in iter(lambda: self.file.read(4096), b""):
f.writeframesraw(chunk)
with self._file_lock:
self.file.seek(0)
for chunk in iter(lambda: self.file.read(4096), b""):
f.writeframesraw(chunk)

if self.volume != 100:
# Reduce the audio volume
factor = self.volume / 100
audio = pydub.AudioSegment.from_file(file=self.audio_filename, format="wav")
audio = pydub.AudioSegment.from_file(file=filename, format="wav")
audio += pydub.audio_segment.ratio_to_db(factor)
temp_filename = self.audio_filename + ".temp"
temp_filename = filename + ".temp"
audio.export(temp_filename, format="wav")
self.delete_audio()
shutil.move(temp_filename, self.audio_filename)

# Get audio length
self.audio_length_ms = self.get_audio_length()
try:
os.remove(filename)
except FileNotFoundError:
pass
shutil.move(temp_filename, filename)

def change_filename(self, new_filename):
self.set_filename(new_filename)
self.compute_audio()

def get_total_bytes(self):
if self.file is None:
return 0
return os.fstat(self.file.fileno()).st_size

def get_file_bytes(self, address, count):
self.file.seek(address)
return self.file.read(count)
if self.file is None:
raise ValueError("No file is open")
if count <= 0:
raise ValueError("count must be positive")
if address < 0:
address = 0
with self._file_lock:
self.file.seek(address)
return self.file.read(count)

def get_address(self, ms):
audio_length_ms = self.get_audio_length_ms()
total_bytes = self.get_total_bytes()
if not audio_length_ms or not total_bytes:
return 0

# Get the size of a single "block" (a row, we only move in increments of 1 row)
address_block_size = self.width * self.color_bytes

# Get the total number of blocks (rows) in the file (round up because we don't want to clip a row off)
total_blocks = math.ceil(self.total_bytes / address_block_size)
total_blocks = math.ceil(total_bytes / address_block_size)

# Get the block index of the current audio location
address_block_index = round(total_blocks * (ms / self.audio_length_ms))
address_block_index = round(total_blocks * (ms / audio_length_ms))

# Adjust index for other alignments
if self.alignment == constants.AlignmentCode.START:
Expand Down Expand Up @@ -502,11 +484,9 @@ def get_frame_qimage(self, ms):
qimg = qimg.mirrored(horizontal=self.flip_h, vertical=self.flip_v)

return qimg

def cleanup(self):
self.close_file()
self.delete_audio()
shutil.rmtree(self.temp_dir)


# Watermarker class
Expand Down
Loading