diff --git a/atmorepl.py b/atmorepl.py index a14d117..5d8f2f4 100644 --- a/atmorepl.py +++ b/atmorepl.py @@ -7,7 +7,9 @@ class WrongArgumentCount(Exception): def __init__(self, command, expected, found): if isinstance(expected, list): expected = "{} to {}".format(**expected) - msg = "Command {} expects {} arguments, found {}".format(command, expected, found) + msg = "Command {} expects {} arguments, found {}".format( + command, expected, found + ) super(WrongArgumentCount, self).__init__(msg) @@ -26,7 +28,7 @@ class AtmoClientHandler(socketserver.StreamRequestHandler): def handle(self): try: while not self.quit: - self.send(">>> ", end='') + self.send(">>> ", end="") data = self.rfile.readline().decode().strip() tokens = data.split() if len(tokens) > 0: @@ -42,10 +44,19 @@ class AtmoClientHandler(socketserver.StreamRequestHandler): self._ensure_args(cmd, 0, args) self.atmo.load_sounds() self.send("Sounds reloaded") + elif cmd == "status": + self._ensure_args(cmd, 0, args) + self.send("Currently playing:") + s = self.atmo.get_status() + for line in s.splitlines(): + self.send(line) + elif cmd == "pause": + self._ensure_args(cmd, 0, args) + is_paused = self.atmo.pause() + self.send("Paused" if is_paused else "Resume") elif cmd == "help": self._ensure_args(cmd, 0, args) - self.send("The following commands are available: ") - self.send(" reload") + self.send_usage() elif cmd == "exit": self.quit = True self.send("Bye") @@ -53,11 +64,16 @@ class AtmoClientHandler(socketserver.StreamRequestHandler): raise CommandNotFound(cmd) except CommandNotFound as e: self.send("Error: {}".format(e)) - self.send("The following commands are available: ") - self.send(" reload") + self.send_usage() except WrongArgumentCount as e: self.send("Error: {}".format(e)) + def send_usage(self): + self.send("The following commands are available: ") + self.send(" reload") + self.send(" status") + self.send(" pause") + @staticmethod def _ensure_args(cmd, expected, args): if isinstance(expected, list): @@ -67,7 +83,7 @@ class AtmoClientHandler(socketserver.StreamRequestHandler): if len(args) != expected: raise WrongArgumentCount(cmd, expected, len(args)) - def send(self, line, end='\n'): + def send(self, line, end="\n"): self.wfile.write("{}{}".format(line, end).encode()) @@ -77,7 +93,7 @@ class ReusableThreadingTCPServer(socketserver.ThreadingTCPServer): class AtmoReplRunner(threading.Thread): - def __init__(self, atmo, host='0.0.0.0', port=7723): + def __init__(self, atmo, host="0.0.0.0", port=7723): super(AtmoReplRunner, self).__init__() AtmoClientHandler.atmo = atmo self._client = ReusableThreadingTCPServer((host, port), AtmoClientHandler) diff --git a/campatmo.py b/campatmo.py index 66ee46f..5e2c619 100755 --- a/campatmo.py +++ b/campatmo.py @@ -1,92 +1,65 @@ #!/usr/bin/env python3 -import time -import sys -import random from glob import glob +import os +import random +import sys from threading import Thread +import time from typing import Union, List, Mapping + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" from pygame import mixer +from collection import SampleCollection from atmorepl import AtmoReplRunner VERBOSITY = 0 - -class SoundCollection: - def __init__( - self, name, files: Union[List[str], Mapping[str, List[str]]], volume=0.5 - ): - self.name = name - self.topics = files.keys() if hasattr(files, "keys") else [] - if self.topics: - self.files = files - else: - self.files = {"None": files} - self.flat_files = [f for topic in self.files for f in self.files[topic]] - self.volume = volume - self.cur_topic = None - self.load_all_files() - self.sound_indices = {name: 0 for name in self.sounds} - self.flat_sound_index = 0 - - def load_all_files(self): - self.sounds = {} - if self.topics: - for t in self.topics: - self.load_topic_files(t) - else: - self.load_topic_files("None") - self.flat_sounds = [s for topic in self.sounds for s in self.sounds[topic]] - random.shuffle(self.flat_sounds) - - def load_topic_files(self, topic): - self.sounds[topic] = [] - for f in self.files[topic]: - sound = mixer.Sound(f) - sound.set_volume(self.volume) - self.sounds[topic].append(sound) - random.shuffle(self.sounds[topic]) - - def next(self): - if self.cur_topic is None: - if len(self.flat_sounds) == 0: - return - self.flat_sound_index += 1 - if self.flat_sound_index >= len(self.flat_sounds): - random.shuffle(self.flat_sounds) - self.flat_sound_index = 0 - return self.flat_sounds[self.flat_sound_index] - else: - if len(self.sounds[self.cur_topic]) == 0: - return - self.sound_indices[self.cur_topic] += 1 - if self.sound_indices[self.cur_topic] >= len(self.sounds[self.cur_topic]): - random.shuffle(self.sounds[self.cur_topic]) - self.sound_indices[self.cur_topic] = 0 - return self.sounds[self.cur_topic][self.sound_indices[self.cur_topic]] - - def set_topic(self, topic=None): - if self.topics: - self.cur_topic = topic - - def __len__(self): - if self.cur_topic is None: - return len(self.flat_sounds) - else: - return len(self.sounds[self.cur_topic]) +channel_index = 0 class CampAtmo: tracks_per_type = 2 - single_track_types = ["lines"] + single_track_types = ["lines", "reactions"] background_track_types = ["background", "buzzwords"] + type_mix = { + "lines": 0.8, + "reactions": 0.7, + "buzzwords": 0.6, + "background": 0.3, + "other": 0.5, + } lines_delta_sec = (5, 15) line_reaction_wait_sec = 0.8 def __init__(self): self.stopped = False mixer.init(frequency=22050 * 2) - self.load_sounds() + type_dirs = glob("samples/*/") + self.types = {} + for type_dir in type_dirs: + type_name = type_dir.split("/")[1] + self.types[type_name] = SampleCollection( + type_dir, volume=self.type_mix[type_name] + ) + i = 0 + mixer.set_num_channels( + sum( + [ + 1 if name in self.single_track_types else self.tracks_per_type + for name in self.types + ] + ) + ) + v(f"{mixer.get_num_channels()} channels set") + self.type_channels = {} + for type_name in self.types: + self.type_channels[type_name] = [] + for k in range( + 1 if type_name in self.single_track_types else self.tracks_per_type + ): + self.type_channels[type_name].append(mixer.Channel(i)) + i += 1 def start(self): self.background_thread = Thread( @@ -104,80 +77,70 @@ class CampAtmo: self.lines_thread.join(2) def load_sounds(self): - type_names = glob("samples/*/") - type_files = {} - for type_dir in type_names: - type_name = type_dir.split("/")[1] - flat_files = glob(type_dir + "/*.ogg") - type_files[type_name] = flat_files - topic_files = glob(type_dir + "/*/*.ogg") - if topic_files: - type_files[type_name] = {} - for topic_dir in glob(type_dir + "/*/"): - topic_name = topic_dir.split("/")[2] - type_files[type_name][topic_name] = glob(type_dir + "/*/*.ogg") - self.types = {} - i = 0 - mixer.set_num_channels( - sum( - [ - 1 if name in self.single_track_types else self.tracks_per_type - for name in type_files - ] - ) - ) - v(f"{mixer.get_num_channels()} channels set") - for name in type_files: - self.types[name] = [] - for k in range( - 1 if name in self.single_track_types else self.tracks_per_type - ): - self.types[name].append(mixer.Channel(i)) - i += 1 - self.sounds = {} - for name, files in type_files.items(): - self.sounds[name] = SoundCollection( - name, files, volume=0.1 if name in self.background_track_types else 0.9 - ) - - def manage_background_queue(self, name, type_channels): - for c in type_channels: - if c.get_queue() is not None and len(self.sounds[name]) > 1: - continue - v(name) - c.queue(self.sounds[name].next()) - if c.get_queue() is None and len(self.sounds[name]) > 1: - c.queue(self.sounds[name].next()) + for _, collection in self.types.items(): + collection.load() def run_forever(self): while not self.stopped: - for name, type_channels in self.types.items(): + for type_name, channels in self.type_channels.items(): if ( - name not in self.background_track_types - or len(self.sounds[name]) == 0 + type_name not in self.background_track_types + or len(self.types[type_name]) == 0 ): continue - self.manage_background_queue(name, type_channels) + self.play_type_sounds(type_name) time.sleep(1) - self.load_sounds() - - def play_type_sounds(self, type_name) -> int: - if len(self.sounds[type_name]) == 0: - return - max_sound_length = 0 - for channel in self.types[type_name]: - sound = self.sounds[type_name].next() - max_sound_length = max(max_sound_length, sound.get_length()) - channel.queue(sound) - return max_sound_length def run_lines_forever(self): while not self.stopped: time.sleep(random.randrange(*self.lines_delta_sec)) length_sec = self.play_type_sounds("lines") time.sleep(length_sec + self.line_reaction_wait_sec) + length_sec = self.play_type_sounds("reactions") + time.sleep(length_sec + self.line_reaction_wait_sec) self.play_type_sounds("reactions") + def play_type_sounds(self, type_name) -> int: + if type_name in self.types and len(self.types[type_name]) == 0: + return + max_sound_length = 0 + for channel in self.type_channels[type_name]: + if channel.get_queue() is not None and len(self.types[type_name]) > 1: + continue + sample = self.types[type_name].next() + max_sound_length = max(max_sound_length, sample.sound.get_length()) + channel.queue(sample.sound) + if ( + type_name not in self.single_track_types + and channel.get_queue() is None + and len(self.types[type_name]) > 1 + ): + channel.queue(self.types[type_name].next_sound()) + return max_sound_length + + def get_status(self): + ret_strs = [] + for type_name, channels in self.type_channels.items(): + samples = [] + for chan in channels: + sample = self.types[type_name].get_sample_from_channel(chan) + if sample is not None: + samples.append(sample) + if samples: + sample_infos = [ + f"{s.path} @{s.sound.get_volume():.0%}vol" for s in samples + ] + ret_strs.append(f"{type_name}: {sample_infos}") + return "\n".join(ret_strs) + + def pause(self): + if mixer.get_busy(): + mixer.pause() + return False + else: + mixer.unpause() + return True + def main(): atmo = CampAtmo() @@ -186,7 +149,7 @@ def main(): atmo.start() try: while True: - time.sleep(100) + time.sleep(10) except KeyboardInterrupt: print("exiting...") atmo.stop() diff --git a/collection.py b/collection.py new file mode 100644 index 0000000..6f229fd --- /dev/null +++ b/collection.py @@ -0,0 +1,161 @@ +import os +import time +from random import shuffle +from dataclasses import dataclass, field + +os.environ["PYGAME_HIDE_SUPPORT_PROMPT"] = "hide" +from pygame import mixer +from typing import Union, Tuple, List +from dirs import list_files_relative + + +@dataclass +class Sample: + path: str + abspath: str + mtime: float + size: int + sound: mixer.Sound + old_sounds: List[Tuple[mixer.Sound, float]] = field(default_factory=list) + + +class SampleCollection: + extensions = ["ogg"] + + def __init__(self, sample_dir: str, volume: float = 0.7): + self.db = [] + self.volume = volume + self.topic_db = {} + self.sample_dir = sample_dir + self.index = 0 + self.load() + + def __len__(self): + return len(self.db) + + def load(self): + prev_prefix = "" + for path, stat in list_files_relative( + self.sample_dir, extensions=self.extensions + ): + db_sample = self.get_sample(path) + if ( + db_sample is not None + and db_sample.mtime == int(stat.st_mtime) + and db_sample.size == stat.st_size + ): + continue + abspath = os.path.join(self.sample_dir, path) + if stat.st_size == 0: + print(f"Warning, empty file: {abspath!r}", file=sys.stderr) + continue + topic = None + last_sep = path.rfind("/") + if last_sep > 0: + prefix = path[:last_sep] + topic = prefix + if prev_prefix != prefix: + prev_prefix = prefix + self.add_sample(path, stat, abspath, topic, db_sample) + + def add_sample( + self, + path: str, + stat: os.stat_result, + abspath: str, + topic: Union[str, None], + known_sample: Sample, + ): + if known_sample is None: + new_sample = Sample( + path=path, + mtime=stat.st_mtime, + size=stat.st_size, + abspath=abspath, + sound=mixer.Sound(abspath), + ) + new_sample.sound.set_volume(self.volume) + self.db.append(new_sample) + if topic is not None: + if topic not in self.topic_db: + self.topic_db[topic] = [new_sample.path] + else: + self.topic_db[topic].append(new_sample.path) + else: + known_sample.old_sounds.append((known_sample.sound, time.time())) + known_sample.sound = mixer.Sound(abspath) + known_sample.sound.set_volume(self.volume) + known_sample.mtime = stat.st_mtime + known_sample.size = stat.st_size + + def set_volume(self, volume): + self.volume = volume + for sample in self.db: + sample.sound.set_volume(volume) + + def next(self): + if self.index == 0: + shuffle(self.db) + ret_sample = self.db[self.index] + self.index = (self.index + 1) % len(self.db) + return ret_sample + + def next_sound(self): + return self.next().sound + + def next_by_topic(self, topic: str): + if topic not in self.topic_db: + return + start_index = self.index + sample = self.next() + while sample.path not in self.topic_db[topic] and self.index != start_index: + print(f"trying sample {sample.path!r} for topic {topic!r}") + sample = self.next() + if self.index == start_index: + return + return sample + + def next_sound_by_topic(self, topic: str): + next_sample = self.next_by_topic(topic) + if next_sample is not None: + return next_sample.sound + + def get_sample(self, path: str) -> Union[dict, None]: + for sample in self.db: + if sample.path == path: + return sample + + def get_sample_from_channel(self, channel): + ret_sample = None + c_sound = channel.get_sound() + if c_sound is None: + return None + for sample in self.db: + if sample.sound == c_sound: + ret_sample = sample + if ret_sample is None: + for sample in self.db: + for o_sample, _ in sample.old_sounds: + if o_sample == c_sound: + ret_sample = sample + for sample in self.db: + expired = [] + for i, (o_sample, exp_time) in enumerate(sample.old_sounds): + if ((time.time() - exp_time) >= o_sample.get_length()) or ( + time.time() - exp_time < 0 + ): + expired.append(i) + for i in reversed(expired): + sample.old_sounds.pop(i) + return ret_sample + + +# if __name__ == "__main__": +# mixer.init() +# chan = mixer.Channel(0) +# coll = SampleCollection("/home/jakob/dev/seba-audio/gincloud/samples/buzzwords") +# chan.queue(coll.next_sound_by_topic("agile")) +# chan.queue(coll.next_sound_by_topic("cloud")) +# print(coll.get_sample_from_channel(chan).path) +# time.sleep(7) +# print(coll.get_sample_from_channel(chan).path) diff --git a/dirs.py b/dirs.py new file mode 100644 index 0000000..61fce5a --- /dev/null +++ b/dirs.py @@ -0,0 +1,35 @@ +import os + + +def list_files_absolute(start_dir, extensions=None): + start_dir = os.path.expanduser(start_dir) + return _list_files(start_dir, start_dir, extensions) + + +def list_files_relative(start_dir, extensions=None): + start_dir = os.path.expanduser(start_dir) + return _list_files(start_dir, start_dir, extensions, relative=True) + + +def _list_files(start_dir, cur_dir, extensions=None, relative=False): + paths = [] + with os.scandir(cur_dir) as scanner: + for entry in scanner: + if entry.is_dir(): + paths += _list_files(start_dir, entry.path, extensions, relative) + elif ( + extensions is not None + and any([entry.name.endswith("." + ext) for ext in extensions]) + ) or extensions is None: + if relative: + name = os.path.relpath(entry.path, start=start_dir) + else: + name = entry.path + paths.append((name, entry.stat())) + return paths + + +if __name__ == "__main__": + import doctest + + doctest.testmod() diff --git a/samples/buzzwords/agile/standup.ogg b/samples/buzzwords/agile/standup.ogg new file mode 100644 index 0000000..2d97a00 Binary files /dev/null and b/samples/buzzwords/agile/standup.ogg differ diff --git a/samples/buzzwords/agile/tickets-abarbeiten.ogg b/samples/buzzwords/agile/tickets-abarbeiten.ogg new file mode 100644 index 0000000..01df57d Binary files /dev/null and b/samples/buzzwords/agile/tickets-abarbeiten.ogg differ diff --git a/samples/buzzwords/cloud/ab-in-die-cloud.ogg b/samples/buzzwords/cloud/ab-in-die-cloud.ogg new file mode 100644 index 0000000..43c43c2 Binary files /dev/null and b/samples/buzzwords/cloud/ab-in-die-cloud.ogg differ