Parcourir la source

initial commit

psy il y a 5 mois
Parent
commit
a72c027bd7

+ 38 - 3
README.md

@@ -1,4 +1,39 @@
-# pyTremor
 
-Seismoacoustics — the combined study of vibrations in the Earth and sound waves in the atmosphere 
-to characterize non-earthquake geohazards, such as avalanches, landslides, and volcanic eruptions.
+![c](https://03c8.net/images/pytremor-banner.png)
+
+----------
+
+#### Info:
+ 
+ Seismoacoustics — the combined study of vibrations in the Earth and sound waves in the atmosphere 
+to characterize non-earthquake geohazards, such as avalanches, landslides, and volcanic eruptions.
+
+ ----------
+
+#### Installing:
+
+ This tool runs on many platforms and it requires Python (3.x.y). You can install all related libs running this command:
+ 
+ python3 setup.py install
+
+#### Launching:
+  
+ python3 pytremor.py --help
+
+----------
+
+#### License:
+
+ PyTremor is released under the GPLv3.
+
+#### Contact:
+
+      - https://krakenslab.com
+
+#### Contribute: 
+
+ To make donations use the following hashes:
+  
+     - Bitcoin: 19aXfJtoYJUoXEZtjNwsah2JKN9CK5Pcjw
+     - Ecoin: ETsRCBzaMawx3isvb5svX7tAukLdUFHKze
+

+ 6 - 0
autoconfig

@@ -0,0 +1,6 @@
+LASTHOURS=24
+
+#ALASKA{network=AV,station=ILSW,channel=BHZ,freqmin=1,freqmax=23,speed_up_factor=200,fps=1,spec_win_dur=8,db_lim=-180|-130}
+#ITALY{network=3D,station=OEM9,channel=HHZ,freqmin=1,freqmax=23,speed_up_factor=200,fps=1,spec_win_dur=8,db_lim=-180|-130}
+#COLOMBIA{network=CM,station=RUS,channel=BHZ,freqmin=1,freqmax=23,speed_up_factor=200,fps=1,spec_win_dur=8,db_lim=-180|-130}
+#FRANCE{network=FR,station=CURIE,channel=HHZ,freqmin=1,freqmax=23,speed_up_factor=200,fps=1,spec_win_dur=8,db_lim=-180|-130}

+ 11 - 0
config

@@ -0,0 +1,11 @@
+network=AV
+station=ILSW
+channel=BHZ
+starttime=2019, 6, 20, 23, 10
+endtime=2019, 6, 21, 0, 30
+freqmin=1
+freqmax=23
+speed_up_factor=200
+fps=1
+spec_win_dur=8
+db_lim=-180,-130

+ 24 - 0
docs/AUTHOR

@@ -0,0 +1,24 @@
+========================
+
+ webs: 
+ 
+ - https://krakenslab.com
+ - https://victormazon.com
+
+=======================
+
+ code:
+
+ - https://code.03c8.net/krakenslab/pytremor
+
+=======================
+
+ BTC: 
+
+  19aXfJtoYJUoXEZtjNwsah2JKN9CK5Pcjw
+  
+ ECO:
+ 
+  ETsRCBzaMawx3isvb5svX7tAukLdUFHKze
+
+========================

+ 46 - 0
docs/COMMITMENT

@@ -0,0 +1,46 @@
+GPL Cooperation Commitment
+Version 1.0
+
+Before filing or continuing to prosecute any legal proceeding or claim
+(other than a Defensive Action) arising from termination of a Covered
+License, we commit to extend to the person or entity ('you') accused
+of violating the Covered License the following provisions regarding
+cure and reinstatement, taken from GPL version 3. As used here, the
+term 'this License' refers to the specific Covered License being
+enforced.
+
+    However, if you cease all violation of this License, then your
+    license from a particular copyright holder is reinstated (a)
+    provisionally, unless and until the copyright holder explicitly
+    and finally terminates your license, and (b) permanently, if the
+    copyright holder fails to notify you of the violation by some
+    reasonable means prior to 60 days after the cessation.
+
+    Moreover, your license from a particular copyright holder is
+    reinstated permanently if the copyright holder notifies you of the
+    violation by some reasonable means, this is the first time you
+    have received notice of violation of this License (for any work)
+    from that copyright holder, and you cure the violation prior to 30
+    days after your receipt of the notice.
+
+We intend this Commitment to be irrevocable, and binding and
+enforceable against us and assignees of or successors to our
+copyrights.
+
+Definitions
+
+'Covered License' means the GNU General Public License, version 2
+(GPLv2), the GNU Lesser General Public License, version 2.1
+(LGPLv2.1), or the GNU Library General Public License, version 2
+(LGPLv2), all as published by the Free Software Foundation.
+
+'Defensive Action' means a legal proceeding or claim that We bring
+against you in response to a prior proceeding or claim initiated by
+you or your affiliate.
+
+'We' means each contributor to this repository as of the date of
+inclusion of this file, including subsidiaries of a corporate
+contributor.
+
+This work is available under a Creative Commons Attribution-ShareAlike
+4.0 International license (https://creativecommons.org/licenses/by-sa/4.0/).

Fichier diff supprimé car celui-ci est trop grand
+ 52 - 74
LICENSE


+ 444 - 0
pyTREMOR.py

@@ -0,0 +1,444 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+pyTREMOR - Python /Seismoacoustics/ Squeezer - 2023-2024 - by Kräken.LABS (https://krakenslab.com)
+
+----------
+
+Seismoacoustics — the combined study of vibrations in the Earth and sound waves in the atmosphere 
+to characterize non-earthquake geohazards, such as avalanches, landslides, and volcanic eruptions.
+
+https://code.03c8.net/krakenslab/pytremor
+
+----------
+
+You should have received a copy of the GNU General Public License along
+with PyTREMOR; if not, write to the Free Software Foundation, Inc., 51
+Franklin St, Fifth Floor, Boston, MA  02110-1301  USA
+
+"""
+VERSION = str(0.5)
+
+import datetime
+from datetime import timedelta
+import os, sys
+import shutil
+from os import environ
+environ['PYGAME_HIDE_SUPPORT_PROMPT'] = '1'
+
+import pygame
+import subprocess
+from obspy import UTCDateTime
+
+import warnings
+def custom_filter(message, category, filename, lineno, *args, **kwargs):
+    return not ('Font family' in str(message) and 'cursive' in str(message))
+warnings.simplefilter('ignore', custom_filter)
+
+def banner():
+    print(r'''    __   _______         __  __            
+ _ _\ \ / /_   _| __ ___|  \/  | ___  _ __ 
+| '_ \ V /  | || '__/ _ \ |\/| |/ _ \| '__|
+| |_) | |   | || | |  __/ |  | | (_) | |   
+| .__/|_|   |_||_|  \___|_|  |_|\___/|_|   
+|_|  pyTREMOR (v'''+VERSION+''') by Kräken.LABS | (2023/2024)
+    ''')
+
+import os
+import shutil
+import datetime
+from obspy import UTCDateTime
+
+def sonify(network_conf, station_conf, channel_conf, starttime_conf, endtime_conf, freqmax_conf, freqmin_conf, speed_up_factor_conf, fps_conf, spec_win_dur_conf, db_lim_conf):
+    db_lim_conf = db_lim_conf.replace("\n", "")
+    db_lim_conf_1 = int(db_lim_conf.split(",")[0])
+    db_lim_conf_2 = int(db_lim_conf.split(",")[1])
+
+    from sonify.sonify.sonify import sonify
+
+    try:
+        print(f"\nSonifying data for: {station_conf}...")
+        result = sonify(
+            network=network_conf.replace("\n", ""),
+            station=station_conf.replace("\n", ""),
+            channel=channel_conf.replace("\n", ""),
+            starttime=UTCDateTime(starttime_conf),
+            endtime=UTCDateTime(endtime_conf),
+            freqmax=int(freqmax_conf.replace("\n", "")),
+            freqmin=int(freqmin_conf.replace("\n", "")),
+            speed_up_factor=int(speed_up_factor_conf.replace("\n", "")),
+            fps=int(fps_conf.replace("\n", "")),
+            spec_win_dur=int(spec_win_dur_conf.replace("\n", "")),
+            db_lim=(db_lim_conf_1, db_lim_conf_2),
+        )
+        print("Sonification completed successfully.")
+        now = datetime.datetime.now()
+        filename = now.strftime("%Y-%m-%d-%H-%M-") + station_conf.replace("\n", "") + ".mp4"
+        dataset_folder = "dataset"
+        if not os.path.exists(dataset_folder):
+            os.makedirs(dataset_folder)
+        new_filepath = os.path.join(dataset_folder, filename)  
+        mp4_files = [file for file in os.listdir(".") if file.endswith(".mp4")]
+        if mp4_files:
+            mp4_file = mp4_files[0] 
+            shutil.move(mp4_file, new_filepath)
+            print(f"Video moved and renamed to: {new_filepath}")
+            menu()
+            return True
+        else:
+            print("No .mp4 file found in the directory.")
+            return False
+    except Exception as e:
+        print(f"An error occurred during sonification: {str(e)}")
+        return False
+
+
+def menu():
+    main_menu = {
+        "(h)elp    ":"show this message",
+        "(v)ersion ":"current version",
+        "(c)lear   ":"clear screen",
+        "(q)uit    ":"quit program"
+    }
+    sonification_menu = {
+        "(setup)   ":"setup template",
+        "(view)    ":"view template",
+        "(run)     ":"run template",
+        "(play)    ":"play sonification"
+    }
+    clear()
+    while 1:
+        cmd = input("pyTREMOR@GSN% ")
+        if cmd == "h" or cmd == "help":
+            print()
+            for n in main_menu:
+                print("    "+ n + ": " + main_menu[n])
+            print()
+            for n in sonification_menu:
+                print("    "+ n + ": " + sonification_menu[n])
+            print()
+        elif cmd == "v" or cmd == "version":
+            banner()
+        elif cmd == "c" or cmd == "clear":
+            clear()
+        elif cmd == "setup":
+            setup()
+        elif cmd == "view":
+            view()
+        elif cmd == "run":
+            run_from_config_file()
+        elif cmd == "play":
+            play_sonification()
+        elif cmd == "q" or cmd == "quit" or cmd == "exit":
+            quit()
+
+def clear():
+    os.system('clear')
+
+def quit():
+    sys.exit()
+    
+def read_config(file):
+    if file == "config":
+        f = open("config","r")
+    else:
+        f = open("autoconfig","r")   
+    lines = f.readlines()
+    f.close()
+    return lines
+    
+import subprocess
+
+def play_sonification():
+    dataset_folder = "dataset"
+    if not os.path.exists(dataset_folder):
+        print("No sonification files found.")
+        return
+    
+    mp4_files = [file for file in os.listdir(dataset_folder) if file.endswith(".mp4")]
+    wav_files = [file for file in os.listdir(dataset_folder) if file.endswith(".wav")]
+
+    files = []
+    for mp4_file in mp4_files:
+        base_name = os.path.splitext(mp4_file)[0]
+        if f"{base_name}.wav" in wav_files:
+            files.append(mp4_file)
+        else:
+            files.append(mp4_file)
+
+    if not files:
+        print("No sonification files found.")
+        return
+
+    print("Available sonification files:")
+    for i, filename in enumerate(files):
+        print(f"{i+1}. {filename}")
+    
+    selection = input("Choose a file to play (enter number): ")
+    try:
+        selection = int(selection)
+        if selection < 1 or selection > len(files):
+            print("Invalid selection.")
+            return
+        selected_file = files[selection - 1]
+        mp4_file = os.path.join(dataset_folder, selected_file)
+        wav_file = os.path.splitext(selected_file)[0] + ".wav"
+        wav_path = os.path.join(dataset_folder, wav_file)
+        if not os.path.exists(wav_path):
+            subprocess.run(["ffmpeg", "-i", mp4_file, wav_path])
+        pygame.init()
+        pygame.mixer.init()
+        pygame.mixer.music.load(wav_path)
+        pygame.mixer.music.play()
+        while pygame.mixer.music.get_busy():
+            pygame.time.Clock().tick(10)
+        pygame.mixer.quit()
+    except ValueError:
+        print("Invalid input. Please enter a number.")
+
+def setup():
+    network = input("\n    set network (str) (ex: AV): ")
+    station = input("    set station (str) (ex: ILSW): ")
+    channel = input("    set channel (str) (ex: BHZ): ")
+    starttime = input("    set starttime (ex: 2019, 6, 20, 23, 10): ")
+    endtime = input("    set endtime (ex: 2019, 6, 21, 0, 30): ")
+    freqmin = input("    set freqmin (int or float) (ex: 1): ")
+    freqmax = input("    set freqmax (int or float) (ex: 23): ")
+    speed_up_factor = input("    set speed_up_factor (int) (ex: 200): ")
+    fps = input("    fps (int) (ex: 1): ")
+    spec_win_dur = input("    set spec_win_dur (int or float) (ex: 8): ")
+    db_lim = input("    set db_lim (tuple or str) (ex: -180,-130): ")
+    
+    with open("config", mode='w') as config:
+        config.write("network="+network+"\n")
+        config.write("station="+station+"\n")
+        config.write("channel="+channel+"\n")
+        config.write("starttime="+starttime+"\n")
+        config.write("endtime="+endtime+"\n")
+        config.write("freqmin="+freqmin+"\n")
+        config.write("freqmax="+freqmax+"\n")
+        config.write("speed_up_factor="+speed_up_factor+"\n")
+        config.write("fps="+fps+"\n")          
+        config.write("spec_win_dur="+spec_win_dur+"\n")       
+        config.write("db_lim="+db_lim)       
+        config.close()
+
+def view():
+    if os.path.isfile("config"):
+       lines = read_config("config") 
+    else:
+       with open("config", mode='w') as config:
+           config.write("network=\n")
+           config.write("station=\n")
+           config.write("channel=\n")
+           config.write("starttime=\n")
+           config.write("endtime=\n")
+           config.write("freqmin=\n")
+           config.write("freqmax=\n")
+           config.write("speed_up_factor=\n")
+           config.write("fps=\n")          
+           config.write("spec_win_dur=\n")       
+           config.write("db_lim=")       
+           config.close()      
+       lines = read_config("config")      
+    print("\n", "sonify(")
+    for line in lines:
+        line = line.replace("\n","")
+        print("       ",line)
+    print("       )\n")
+
+def run(network_conf, station_conf, channel_conf, starttime_conf, endtime_conf, freqmax_conf, freqmin_conf, speed_up_factor_conf, fps_conf, spec_win_dur_conf, db_lim_conf):
+    print("\n[+] Generating -sonify- setup from LOCAL config...")
+    if os.path.isfile("config"):
+       lines = read_config("config") 
+    else:
+       print("\n[Error] not 'config' file generated... Exiting!\n")
+       sys.exit(2)
+
+    for line in lines:   
+        if "network" in line:
+            network_conf = line.split("=")[1]
+        if "station" in line:
+            station_conf = line.split("=")[1]
+        if "channel" in line:
+            channel_conf = line.split("=")[1]
+        if "starttime" in line:
+            starttime_conf = line.split("=")[1]    
+        if "endtime" in line:
+            endtime_conf = line.split("=")[1]        
+        if "freqmax" in line:
+            freqmax_conf = line.split("=")[1]
+        if "freqmin" in line:
+            freqmin_conf = line.split("=")[1]
+        if "speed_up_factor" in line:
+            speed_up_factor_conf = line.split("=")[1]            
+        if "fps" in line:
+            fps_conf = line.split("=")[1]
+        if "spec_win_dur" in line:
+            spec_win_dur_conf = line.split("=")[1]
+        if "db_lim" in line:
+            db_lim_conf = line.split("=")[1] 
+
+        success = sonify(network_conf, station_conf, channel_conf, starttime_conf, endtime_conf, freqmax_conf, freqmin_conf, speed_up_factor_conf, fps_conf, spec_win_dur_conf, db_lim_conf)
+        if not success:
+            return False
+    return True
+
+def run_from_config_file():
+    lines = read_config("config")
+    network_conf = ""
+    station_conf = ""
+    channel_conf = ""
+    starttime_conf = ""
+    endtime_conf = ""
+    freqmax_conf = ""
+    freqmin_conf = ""
+    speed_up_factor_conf = ""
+    fps_conf = ""
+    spec_win_dur_conf = ""
+    db_lim_conf = ""
+    for line in lines:
+        if "network" in line:
+            network_conf = line.split("=")[1]
+        if "station" in line:
+            station_conf = line.split("=")[1]
+        if "channel" in line:
+            channel_conf = line.split("=")[1]
+        if "starttime" in line:
+            starttime_conf = line.split("=")[1]
+        if "endtime" in line:
+            endtime_conf = line.split("=")[1]
+        if "freqmax" in line:
+            freqmax_conf = line.split("=")[1]
+        if "freqmin" in line:
+            freqmin_conf = line.split("=")[1]
+        if "speed_up_factor" in line:
+            speed_up_factor_conf = line.split("=")[1]
+        if "fps" in line:
+            fps_conf = line.split("=")[1]
+        if "spec_win_dur" in line:
+            spec_win_dur_conf = line.split("=")[1]
+        if "db_lim" in line:
+            db_lim_conf = line.split("=")[1]
+
+    success = run(network_conf, station_conf, channel_conf, starttime_conf, endtime_conf, freqmax_conf, freqmin_conf, speed_up_factor_conf, fps_conf, spec_win_dur_conf, db_lim_conf)
+    if success:
+        menu()
+
+
+def init():
+    if "--cmd" in sys.argv:
+        try:
+            network_conf = sys.argv[2]+"\n"
+            station_conf = sys.argv[3]+"\n"
+            channel_conf = sys.argv[4]+"\n"
+            starttime_conf = sys.argv[5].replace(",",", ")+"\n"
+            endtime_conf = sys.argv[6].replace(",",", ")+"\n"
+            freqmax_conf = sys.argv[7]+"\n"
+            freqmin_conf = sys.argv[8]+"\n"
+            speed_up_factor_conf = sys.argv[9]+"\n"
+            fps_conf = sys.argv[10]+"\n"
+            spec_win_dur_conf = sys.argv[11]+"\n"
+            db_lim_conf = sys.argv[12]+"\n"
+            print("\n[+] Generating -sonify- setup from CMD config...")
+            print("\n", "sonify(")  
+            print("       network="+network_conf.replace("\n",""))
+            print("       station="+station_conf.replace("\n",""))
+            print("       channel="+channel_conf.replace("\n",""))
+            print("       starttime="+starttime_conf.replace("\n",""))
+            print("       endtime="+endtime_conf.replace("\n",""))
+            print("       freqmax="+freqmax_conf.replace("\n",""))
+            print("       freqmin="+freqmin_conf.replace("\n",""))
+            print("       speed_up_factor="+speed_up_factor_conf.replace("\n",""))
+            print("       fps="+fps_conf.replace("\n",""))
+            print("       spec_win_dur="+spec_win_dur_conf.replace("\n",""))
+            print("       db_lim="+db_lim_conf)
+            print("       )")     
+            success = sonify(network_conf, station_conf, channel_conf, starttime_conf, endtime_conf, freqmax_conf, freqmin_conf, speed_up_factor_conf, fps_conf, spec_win_dur_conf, db_lim_conf)
+            if success:
+                menu()
+        except:
+            print("\n[Error] Executing parameters are wrong... Please review your command line!") 
+            print("\n    +SYNTAX:\n       python3 pyTREMOR.py --cmd <network> <station> <channel> <starttime> <endtime> <freqmax> <freqmin> <speed_up_factor> <fps> <spec_win_dur> <db_lim>")
+            print("\n    +EXAMPLE:\n       python3 pyTREMOR.py --cmd AV ILSW BHZ 2019,6,20,23,10 2019,6,21,0,30 23 1 200 1 8 -180,-130\n")
+            
+    elif "--autorun" in sys.argv:
+        print("\n[+] Generating -sonify- setup from LOCAL auto-config...")
+        if os.path.isfile("autoconfig"):
+            lines = read_config("autoconfig") 
+        else:
+            print("\n[Error] not 'autoconfig' file found... Exiting!\n")
+            sys.exit(2)      
+        datetime_format = "%Y, %m, %d, %H, %M"           
+        endtime_conf = datetime.datetime.now()
+        endtime_conf_format = endtime_conf.strftime(datetime_format)
+        print("\n"+"-"*24)
+        for line in lines:
+            if "LASTHOURS" in line:
+                lasthours = line.split("=")[1]    
+                print ("[Info] LAST HOURS: ["+ str(lasthours).replace("\n","")+"]")
+        starttime_conf = endtime_conf - timedelta(hours=int(lasthours))
+        starttime_conf_format = starttime_conf.strftime(datetime_format)       
+        print ("[Info] Starttime: "+str(starttime_conf_format))
+        print ("[Info] Endtime: "+ str(endtime_conf_format))
+        print("-"*24+"\n")
+        stations_list=[]
+        for line in lines:
+            if "#" in line:
+                location = line.split("{")[0]
+                print (location)
+                location_conf = line.split("{")[1]
+                location_conf = location_conf.replace("{", "")
+                location_conf = location_conf.replace("}", "")   
+                location_conf = location_conf.replace("\n", "")   
+                location_list = list(location_conf.split(","))
+                location_list.append("starttime="+starttime_conf_format)
+                location_list.append("endtime="+endtime_conf_format)
+                print("  "+ str(location_list)+"\n")
+                stations_list.append(location_list)
+        print("-"*24+"\n")
+        for station in stations_list:
+            for value in station:
+                if "network" in value:
+                    network_conf = value.split("=")[1]
+                if "station" in value:
+                    station_conf = value.split("=")[1]
+                if "channel" in value:
+                    channel_conf = value.split("=")[1]
+                if "starttime" in value:
+                    starttime_conf = value.split("=")[1]    
+                if "endtime" in value:
+                    endtime_conf = value.split("=")[1]        
+                if "freqmax" in value:
+                    freqmax_conf = value.split("=")[1]
+                if "freqmin" in value:
+                    freqmin_conf = value.split("=")[1]
+                if "speed_up_factor" in value:
+                    speed_up_factor_conf = value.split("=")[1]            
+                if "fps" in value:
+                    fps_conf = value.split("=")[1]
+                if "spec_win_dur" in value:
+                    spec_win_dur_conf = value.split("=")[1]
+                if "db_lim" in value:
+                    db_lim_conf_1 = value.split("|")[0]
+                    db_lim_conf_1 = db_lim_conf_1.split("=")[1]
+                    db_lim_conf_2 = value.split("|")[1]
+                    db_lim_conf=(db_lim_conf_1+","+db_lim_conf_2)
+            success = sonify(network_conf, station_conf, channel_conf, starttime_conf, endtime_conf, freqmax_conf, freqmin_conf, speed_up_factor_conf, fps_conf, spec_win_dur_conf, db_lim_conf)
+            if not success:
+                return False
+    if "--help" in sys.argv:
+        print("="*50)
+        banner()
+        print(" Seismoacoustics — the combined study of vibrations in the Earth and sound waves in the atmosphere\n to characterize non-earthquake geohazards, such as avalanches, landslides, and volcanic eruptions.\n")
+        print("="*50)
+        print("    +HELP:\n       python3 pyTREMOR.py --help")
+        print("-"*50)
+        print("\n    +SHELL:\n       python3 pyTREMOR.py")
+        print("\n    +AUTORUN:\n       python3 pyTREMOR.py --autorun")
+        print("\n    +CMD:\n       python3 pyTREMOR.py --cmd <network> <station> <channel> <starttime> <endtime> <freqmax> <freqmin> <speed_up_factor> <fps> <spec_win_dur> <db_lim>\n")
+    else:
+        menu()
+
+init()
+

+ 15 - 0
setup.py

@@ -0,0 +1,15 @@
+from setuptools import setup
+
+setup(
+    name='pyTREMOR',
+    version='0.5',
+    description='Dependencies for pyTREMOR project',
+    author='psy',
+    author_email='epsylon@riseup.net',
+    install_requires=[
+        'datetime',
+        'pygame',
+        'obspy',
+        'tqdm', 
+    ],
+)

+ 21 - 0
sonify/LICENSE.txt

@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2020-2023 Liam Toney
+
+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:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+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.

+ 134 - 0
sonify/README.rst

@@ -0,0 +1,134 @@
+*sonify*
+========
+
+|docs_badge| |build_badge| |cov_badge| |black_badge| |isort_badge|
+
+*sonify* “squeezes” seismic or infrasound signals into audible frequencies and
+creates animated spectrograms to accompany the audio. Data are pulled from any
+of the `FDSN data centers
+<https://service.iris.edu/irisws/fedcatalog/1/datacenters?format=html>`__
+available through the Incorporated Research Institutions for Seismology (IRIS)
+Data Management Center (DMC) `fedcatalog
+<https://service.iris.edu/irisws/fedcatalog/docs/1/help/>`__ web service.
+
+|screenshot|
+
+*sonify* `won an Honorable Mention
+<https://jhepc.github.io/2020/entry_11/index.html>`__ in the 2020 SciPy `John
+Hunter Excellence in Plotting Contest (JHEPC) <https://jhepc.github.io/>`__.
+
+Quickstart
+----------
+
+1. Obtain
+
+   .. code-block:: xml
+
+     git clone https://github.com/liamtoney/sonify.git
+     cd sonify
+
+2. Create environment, install, and activate (`install conda
+   <https://conda.io/projects/conda/en/latest/user-guide/install/index.html>`__
+   first, if necessary)
+
+   .. code-block:: xml
+
+     conda env create
+     conda activate sonify
+
+3. Run using the Python interpreter
+
+   .. code-block:: python
+
+     python
+     >>> from sonify import sonify
+
+   or via the command-line interface
+
+   .. code-block:: xml
+
+     sonify --help
+
+Example
+-------
+
+To make a movie of the seismic signal generated by a massive avalanche
+occurring in Alaska on 21 June 2019, sped up by a factor of 200:
+
+.. code-block:: python
+
+   from sonify import sonify
+   from obspy import UTCDateTime
+
+   sonify(
+       network='AV',
+       station='ILSW',
+       channel='BHZ',
+       starttime=UTCDateTime(2019, 6, 20, 23, 10),
+       endtime=UTCDateTime(2019, 6, 21, 0, 30),
+       freqmin=1,
+       freqmax=23,
+       speed_up_factor=200,
+       fps=1,  # Use fps=60 to ~recreate the JHEPC entry (slow to save!)
+       spec_win_dur=8,
+       db_lim=(-180, -130),
+   )
+
+Or (equivalently), via the command-line interface:
+
+.. ~BEGIN~
+.. code-block:: xml
+
+  sonify AV ILSW BHZ 2019-06-20T23:10 2019-06-21T00:30 --freqmin 1 --freqmax 23 --speed_up_factor 200 --fps 1 --spec_win_dur 8 --db_lim -180 -130
+.. ~END~
+
+The result is a 4K 1fps video file named ``AV_ILSW_BHZ_200x.mp4``. A screenshot
+of the movie is shown at the top of this README.
+
+Documentation
+-------------
+
+Application programming interface (API) documentation for the module is available
+`here <https://sonify.readthedocs.io/en/latest/sonify.html>`__. For command-line
+usage instructions, type ``sonify --help`` (the ``sonify`` conda environment must
+be active).
+
+.. |docs_badge| image:: https://readthedocs.org/projects/sonify/badge/?version=latest
+   :alt: Documentation status
+   :target: https://sonify.rtfd.io/
+
+.. |build_badge| image:: https://github.com/liamtoney/sonify/actions/workflows/build.yml/badge.svg
+   :alt: Build status
+   :target: https://github.com/liamtoney/sonify/actions/workflows/build.yml
+
+.. |cov_badge| image:: https://codecov.io/gh/liamtoney/sonify/branch/main/graph/badge.svg?token=3OIGM34OFL
+   :alt: Test coverage
+   :target: https://codecov.io/gh/liamtoney/sonify
+
+.. |black_badge| image:: https://img.shields.io/badge/code%20style-black-000000.svg
+   :alt: Link to Black
+   :target: https://black.readthedocs.io/en/stable/
+
+.. |isort_badge| image:: https://img.shields.io/badge/%20imports-isort-%231674b1?style=flat&labelColor=ef8336
+   :alt: Link to isort
+   :target: https://pycqa.github.io/isort/
+
+.. |screenshot| image:: screenshot.png
+   :alt: Screenshot of example
+   :target: #example
+
+Contributing
+------------
+
+If you notice a bug with *sonify* (or if you'd like to request/propose a new
+feature), please `create an issue on GitHub
+<https://github.com/liamtoney/sonify/issues/new>`__ (preferred) or email me at
+|liam@liam.earth|_. You are also welcome to create a `pull request
+<https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/about-pull-requests>`__.
+Please don't allow `imposter syndrome
+<https://en.wikipedia.org/wiki/Impostor_syndrome>`__ to obstruct you from
+contributing your valuable ideas and skills to this project — **I'm happy to help
+you contribute in any way I can.**
+
+.. |liam@liam.earth| replace:: ``liam@liam.earth``
+.. _liam@liam.earth: mailto:liam@liam.earth

+ 10 - 0
sonify/setup.py

@@ -0,0 +1,10 @@
+from setuptools import find_packages, setup
+
+from sonify import __version__
+
+setup(
+    name='sonify',
+    version=__version__,
+    packages=find_packages(),
+    entry_points=dict(console_scripts='sonify = sonify.sonify:main'),
+)

+ 20 - 0
sonify/sonify/__init__.py

@@ -0,0 +1,20 @@
+import subprocess
+from pathlib import Path
+
+__version__ = subprocess.run(
+    [
+        'git',
+        '-C',
+        Path(__file__).resolve().parent,
+        'rev-parse',
+        '--short=7',
+        'HEAD',
+    ],
+    capture_output=True,
+    text=True,
+).stdout.strip()
+
+del subprocess
+del Path
+
+from .sonify import sonify

+ 28 - 0
sonify/sonify/fonts/GUST-FONT-LICENSE.txt

@@ -0,0 +1,28 @@
+% This is version 1.0, dated 22 June 2009, of the GUST Font License.
+% (GUST is the Polish TeX Users Group, http://www.gust.org.pl)
+%
+% For the most recent version of this license see
+% http://www.gust.org.pl/fonts/licenses/GUST-FONT-LICENSE.txt
+% or
+% http://tug.org/fonts/licenses/GUST-FONT-LICENSE.txt
+%
+% This work may be distributed and/or modified under the conditions
+% of the LaTeX Project Public License, either version 1.3c of this
+% license or (at your option) any later version.
+%
+% Please also observe the following clause:
+% 1) it is requested, but not legally required, that derived works be
+%    distributed only after changing the names of the fonts comprising this
+%    work and given in an accompanying "manifest", and that the
+%    files comprising the Work, as listed in the manifest, also be given
+%    new names. Any exceptions to this request are also given in the
+%    manifest.
+%
+%    We recommend the manifest be given in a separate file named
+%    MANIFEST-<fontid>.txt, where <fontid> is some unique identification
+%    of the font family. If a separate "readme" file accompanies the Work,
+%    we recommend a name of the form README-<fontid>.txt.
+%
+% The latest version of the LaTeX Project Public License is in
+% http://www.latex-project.org/lppl.txt and version 1.3c or later
+% is part of all distributions of LaTeX version 2006/05/20 or later.

BIN
sonify/sonify/fonts/JetBrainsMono-Regular.ttf


+ 93 - 0
sonify/sonify/fonts/OFL.txt

@@ -0,0 +1,93 @@
+Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.

BIN
sonify/sonify/fonts/texgyreheros-regular.otf


+ 739 - 0
sonify/sonify/sonify.py

@@ -0,0 +1,739 @@
+#!/usr/bin/env python
+
+import argparse
+import subprocess
+import tempfile
+import warnings
+from pathlib import Path
+from types import MethodType
+
+import matplotlib
+import matplotlib.dates as mdates
+import numpy as np
+from matplotlib import font_manager
+from matplotlib.animation import FuncAnimation
+from matplotlib.figure import Figure
+from matplotlib.gridspec import GridSpec
+from matplotlib.offsetbox import AnchoredText
+from matplotlib.ticker import ScalarFormatter
+from obspy import UTCDateTime
+from obspy.clients.fdsn import RoutingClient
+from obspy.clients.fdsn.client import raise_on_error
+from scipy import signal
+from tqdm import tqdm
+
+from . import __version__
+
+# Add Tex Gyre Heros and JetBrains Mono to Matplotlib
+for font_path in font_manager.findSystemFonts(
+    str(Path(__file__).resolve().parent / 'fonts')
+):
+    font_manager.fontManager.addfont(font_path)
+
+LOWEST_AUDIBLE_FREQUENCY = 20  # [Hz]
+HIGHEST_AUDIBLE_FREQUENCY = 20000  # [Hz]
+
+AUDIO_SAMPLE_RATE = 44100  # [Hz]
+
+PAD = 60  # [s] Extra data to download on either side of requested time slice
+
+# [px] Output video resolution options (width, height)
+RESOLUTIONS = {
+    'crude': (640, 360),
+    '720p': (1280, 720),
+    '1080p': (1920, 1080),
+    '2K': (2560, 1440),
+    '4K': (3840, 2160),
+}
+
+FIGURE_WIDTH = 7.7  # [in] Sets effective font size, basically
+
+# For spectrograms
+REFERENCE_PRESSURE = 20e-6  # [Pa]
+REFERENCE_VELOCITY = 1  # [m/s]
+
+MS_PER_S = 1000  # [ms/s]
+
+# Colorbar extension triangle height as proportion of colorbar length
+EXTENDFRAC = 0.04
+
+
+def sonify(
+    network,
+    station,
+    channel,
+    starttime,
+    endtime,
+    location='*',
+    freqmin=None,
+    freqmax=None,
+    speed_up_factor=200,
+    fps=1,
+    resolution='4K',
+    output_dir=None,
+    spec_win_dur=5,
+    db_lim='smart',
+    log=False,
+    utc_offset=None,
+):
+    r"""
+    Produce an animated spectrogram with a soundtrack derived from sped-up
+    seismic or infrasound data.
+
+    Args:
+        network (str): SEED network code
+        station (str): SEED station code
+        channel (str): SEED channel code
+        starttime (:class:`~obspy.core.utcdatetime.UTCDateTime`): Start time of
+            animation (UTC)
+        endtime (:class:`~obspy.core.utcdatetime.UTCDateTime`): End time of
+            animation (UTC)
+        location (str): SEED location code
+        freqmin (int or float): Lower bandpass corner [Hz] (defaults to 20 Hz /
+            `speed_up_factor`)
+        freqmax (int or float): Upper bandpass corner [Hz] (defaults to 20,000
+            Hz / `speed_up_factor` or the `Nyquist frequency`_, whichever is
+            smaller)
+        speed_up_factor (int): Factor by which to speed up the waveform data
+            (higher values = higher pitches)
+        fps (int): Frames per second of output video
+        resolution (str): Resolution of output video; one of `'crude'` (640
+            :math:`\times` 360), `'720p'` (1280 :math:`\times` 720), `'1080p'`
+            (1920 :math:`\times` 1080), `'2K'` (2560 :math:`\times` 1440), or
+            `'4K'` (3840 :math:`\times` 2160)
+        output_dir (str or :class:`~pathlib.Path`): Directory where output video
+            should be saved (defaults to :meth:`~pathlib.Path.cwd`)
+        spec_win_dur (int or float): Duration of spectrogram window [s]
+        db_lim (tuple or str): Tuple defining min and max colormap cutoffs [dB],
+            `'smart'` for a sensible automatic choice, or `None` for no clipping
+        log (bool): If `True`, use log scaling for :math:`y`-axis of spectrogram
+        utc_offset (int or float): If not `None`, convert UTC time to local time
+            using this offset [hours] before plotting
+
+    .. _Nyquist frequency: https://en.wikipedia.org/wiki/Nyquist_frequency
+    """
+
+    # Capture args and format as string to store in movie metadata
+    key_value_pairs = [f'{k}={repr(v)}' for k, v in locals().items()]
+    call_str = 'sonify({})'.format(', '.join(key_value_pairs))
+
+    # Use current working directory if none provided
+    if not output_dir:
+        output_dir = Path().cwd()
+    output_dir = Path(str(output_dir)).expanduser().resolve()
+    if not output_dir.is_dir():
+        raise FileNotFoundError(f'Directory {output_dir} does not exist!')
+
+    # See https://service.iris.edu/irisws/fedcatalog/1/datacenters?format=html
+    client = RoutingClient('iris-federator')
+
+    print('Retrieving data...')
+    st = client.get_waveforms(
+        network=network,
+        station=station,
+        location=location,
+        channel=channel,
+        starttime=starttime - PAD,
+        endtime=endtime + PAD,
+    )
+    if not st:
+        raise_on_error(204, None)  # If Stream is empty, then raise FDSNNoDataException
+    print('Done')
+
+    # Merge Traces with the same IDs
+    st.merge(fill_value='interpolate')
+
+    if st.count() != 1:
+        warnings.warn('Stream contains more than one Trace. Using first entry!')
+        for tr in st:
+            print(tr.id)
+    tr = st[0]
+
+    # Now that we have just one Trace, get inventory (which has response info)
+    inv = client.get_stations(
+        network=tr.stats.network,
+        station=tr.stats.station,
+        location=tr.stats.location,
+        channel=tr.stats.channel,
+        starttime=tr.stats.starttime,
+        endtime=tr.stats.endtime,
+        level='response',
+    )
+
+    # Adjust starttime so we have nice numbers in time box (carefully!)
+    offset = np.abs(tr.stats.starttime - (starttime - PAD))  # [s]
+    if offset > tr.stats.delta:
+        warnings.warn(
+            f'Difference between requested and actual starttime is {offset} s, '
+            f'which is larger than the data sample interval ({tr.stats.delta} s). '
+            'Not adjusting starttime of downloaded data; beware of inaccurate timing!'
+        )
+    else:
+        tr.stats.starttime = starttime - PAD
+
+    # Apply UTC offset if provided
+    if utc_offset is not None:
+        signed_offset = f'{utc_offset:{"+" if utc_offset else ""}g}'
+        print(f'Converting to local time using UTC offset of {signed_offset} hours')
+        utc_offset_sec = utc_offset * mdates.SEC_PER_HOUR
+        starttime += utc_offset_sec
+        endtime += utc_offset_sec
+        tr.stats.starttime += utc_offset_sec
+
+    # All infrasound sensors have a "?DF" channel pattern
+    if tr.stats.channel[1:3] == 'DF':
+        is_infrasound = True
+        rescale = 1  # No conversion
+    # All high-gain seismometers have a "?H?" channel pattern
+    elif tr.stats.channel[1] == 'H':
+        is_infrasound = False
+        rescale = 1e6  # Convert m to µm
+    # We can't figure out what type of sensor this is...
+    else:
+        raise ValueError(
+            f'Channel {tr.stats.channel} is not an infrasound or seismic channel!'
+        )
+
+    if not freqmax:
+        freqmax = np.min(
+            [tr.stats.sampling_rate / 2, HIGHEST_AUDIBLE_FREQUENCY / speed_up_factor]
+        )
+    if not freqmin:
+        freqmin = LOWEST_AUDIBLE_FREQUENCY / speed_up_factor
+
+    tr.remove_response(inventory=inv)  # Units are m/s OR Pa after response removal
+    tr.detrend('demean')
+    tr.taper(max_percentage=None, max_length=PAD / 2)  # Taper away some of PAD
+    print(f'Applying {freqmin:g}–{freqmax:g} Hz bandpass')
+    tr.filter('bandpass', freqmin=freqmin, freqmax=freqmax, zerophase=True)
+
+    # Make trimmed version
+    tr_trim = tr.copy()
+    tr_trim.trim(starttime, endtime)
+
+    # Create temporary directory for audio and video files
+    temp_dir = tempfile.TemporaryDirectory()
+
+    # MAKE AUDIO FILE
+
+    tr_audio = tr_trim.copy()
+    target_fs = AUDIO_SAMPLE_RATE / speed_up_factor
+    corner_freq = 0.4 * target_fs  # [Hz] Note that Nyquist is 0.5 * target_fs
+    if corner_freq < tr_audio.stats.sampling_rate / 2:  # To avoid ValueError
+        tr_audio.filter('lowpass', freq=corner_freq, corners=10, zerophase=True)
+    tr_audio.interpolate(sampling_rate=target_fs, method='lanczos', a=20)
+    tr_audio.taper(0.01)  # For smooth start and end
+    audio_file = Path(temp_dir.name) / '47.wav'
+    print('Saving audio file...')
+    tr_audio.write(
+        str(audio_file),
+        format='WAV',
+        width=4,
+        rescale=True,
+        framerate=AUDIO_SAMPLE_RATE,
+    )
+    print('Done')
+
+    # MAKE VIDEO FILE
+
+    # We don't need an anti-aliasing filter here since we never use the values,
+    # just the timestamps
+    timing_tr = tr_trim.copy().interpolate(sampling_rate=fps / speed_up_factor)
+    times = timing_tr.times('UTCDateTime')[:-1]  # Remove extra frame
+
+    # Define update function
+    def _march_forward(frame, spec_line, wf_line, time_box, wf_progress):
+        spec_line.set_xdata([times[frame].matplotlib_date])
+        wf_line.set_xdata([times[frame].matplotlib_date])
+        time_box.txt.set_text(times[frame].strftime('%H:%M:%S'))
+        tr_progress = tr.copy().trim(endtime=times[frame])
+        wf_progress.set_xdata(tr_progress.times('matplotlib'))
+        wf_progress.set_ydata(tr_progress.data * rescale)
+
+    # Store user's rc settings, then update font stuff
+    original_params = matplotlib.rcParams.copy()
+    matplotlib.rcParams.update(matplotlib.rcParamsDefault)
+    matplotlib.rcParams['font.sans-serif'] = 'Tex Gyre Heros'
+    matplotlib.rcParams['mathtext.fontset'] = 'custom'
+
+    fig, *fargs = _spectrogram(
+        tr,
+        starttime,
+        endtime,
+        is_infrasound,
+        rescale,
+        spec_win_dur,
+        db_lim,
+        (freqmin, freqmax),
+        log,
+        utc_offset is not None,
+        resolution,
+    )
+
+    # Create animation
+    interval = ((1 / timing_tr.stats.sampling_rate) * MS_PER_S) / speed_up_factor
+    frames_tqdm = tqdm(
+        np.arange(times.size),
+        initial=1,  # Frames start at 1
+        bar_format='{percentage:3.0f}% |{bar}| {n_fmt}/{total_fmt} frames ',
+    )
+    animation = FuncAnimation(
+        fig,
+        func=_march_forward,
+        frames=frames_tqdm,
+        fargs=fargs,
+        interval=interval,
+    )
+
+    video_file = Path(temp_dir.name) / '47.mp4'
+    tqdm.write('Saving animation. This may take a while...')
+    animation.save(
+        video_file,
+        dpi=RESOLUTIONS[resolution][0] / FIGURE_WIDTH,  # Can be a float...
+    )
+    frames_tqdm.close()
+    print('Done')
+
+    # Restore user's rc settings, ignoring Matplotlib deprecation warnings
+    with warnings.catch_warnings():
+        warnings.simplefilter('ignore')
+        matplotlib.rcParams.update(original_params)
+
+    # MAKE COMBINED FILE
+
+    tr_id_str = '_'.join([code for code in tr.id.split('.') if code])
+    output_file = output_dir / f'{tr_id_str}_{speed_up_factor}x.mp4'
+    _ffmpeg_combine(audio_file, video_file, output_file, call_str)
+
+    # Clean up temporary directory, just to be safe
+    temp_dir.cleanup()
+
+
+def _spectrogram(
+    tr,
+    starttime,
+    endtime,
+    is_infrasound,
+    rescale,
+    spec_win_dur,
+    db_lim,
+    freq_lim,
+    log,
+    is_local_time,
+    resolution,
+):
+    """
+    Make a combination waveform and spectrogram plot for an infrasound or
+    seismic signal.
+
+    Args:
+        tr (:class:`~obspy.core.trace.Trace`): Input data, usually starts
+            before `starttime` and ends after `endtime` (this function expects
+            the response to be removed!)
+        starttime (:class:`~obspy.core.utcdatetime.UTCDateTime`): Start time
+        endtime (:class:`~obspy.core.utcdatetime.UTCDateTime`): End time
+        is_infrasound (bool): `True` if infrasound, `False` if seismic
+        rescale (int or float): Scale waveforms by this factor for plotting
+        spec_win_dur (int or float): See docstring for :func:`~sonify.sonify`
+        db_lim (tuple or str): See docstring for :func:`~sonify.sonify`
+        freq_lim (tuple): Tuple defining frequency limits for spectrogram plot
+        log (bool): See docstring for :func:`~sonify.sonify`
+        is_local_time (bool): Passed to :class:`_UTCDateFormatter`
+        resolution (str): See docstring for :func:`~sonify.sonify`
+
+    Returns:
+        Tuple of (`fig`, `spec_line`, `wf_line`, `time_box`, `wf_progress`)
+    """
+
+    if is_infrasound:
+        ylab = 'Pressure (Pa)'
+        clab = f'Power (dB rel. [{REFERENCE_PRESSURE * 1e6:g} µPa]$^2$ Hz$^{{-1}}$)'
+        ref_val = REFERENCE_PRESSURE
+    else:
+        ylab = 'Velocity (µm s$^{-1}$)'
+        if REFERENCE_VELOCITY == 1:
+            clab = (
+                f'Power (dB rel. {REFERENCE_VELOCITY:g} [m s$^{{-1}}$]$^2$ Hz$^{{-1}}$)'
+            )
+        else:
+            clab = (
+                f'Power (dB rel. [{REFERENCE_VELOCITY:g} m s$^{{-1}}$]$^2$ Hz$^{{-1}}$)'
+            )
+        ref_val = REFERENCE_VELOCITY
+
+    fs = tr.stats.sampling_rate
+    nperseg = int(spec_win_dur * fs)  # Samples
+    nfft = np.power(2, int(np.ceil(np.log2(nperseg))) + 1)  # Pad fft with zeroes
+
+    f, t, sxx = signal.spectrogram(
+        tr.data, fs, window='hann', nperseg=nperseg, noverlap=nperseg // 2, nfft=nfft
+    )
+
+    # [dB rel. (ref_val <ref_val_unit>)^2 Hz^-1]
+    sxx_db = 10 * np.log10(sxx / (ref_val**2))
+
+    t_mpl = tr.stats.starttime.matplotlib_date + (t / mdates.SEC_PER_DAY)
+
+    # Ensure a 16:9 aspect ratio
+    fig = Figure(figsize=(FIGURE_WIDTH, (9 / 16) * FIGURE_WIDTH))
+
+    # width_ratios effectively controls the colorbar width
+    gs = GridSpec(2, 2, figure=fig, height_ratios=[2, 1], width_ratios=[40, 1])
+
+    spec_ax = fig.add_subplot(gs[0, 0])
+    wf_ax = fig.add_subplot(gs[1, 0], sharex=spec_ax)  # Share x-axis with spec
+    cax = fig.add_subplot(gs[0, 1])
+
+    wf_lw = 0.5
+    wf_ax.plot(tr.times('matplotlib'), tr.data * rescale, '#b0b0b0', linewidth=wf_lw)
+    wf_progress = wf_ax.plot(np.nan, np.nan, 'black', linewidth=wf_lw)[0]
+    wf_ax.set_ylabel(ylab)
+    wf_ax.grid(linestyle=':')
+    max_value = np.abs(tr.copy().trim(starttime, endtime).data).max() * rescale
+    wf_ax.set_ylim(-max_value, max_value)
+
+    im = spec_ax.pcolormesh(
+        t_mpl, f, sxx_db, cmap='inferno', shading='nearest', rasterized=True
+    )
+
+    spec_ax.set_ylabel('Frequency (Hz)')
+    spec_ax.grid(linestyle=':')
+    spec_ax.set_ylim(freq_lim)
+    if log:
+        spec_ax.set_yscale('log')
+
+    # Tick locating and formatting
+    locator = mdates.AutoDateLocator()
+    wf_ax.xaxis.set_major_locator(locator)
+    wf_ax.xaxis.set_major_formatter(_UTCDateFormatter(locator, is_local_time))
+    fig.autofmt_xdate()
+
+    # "Crop" x-axis!
+    wf_ax.set_xlim(starttime.matplotlib_date, endtime.matplotlib_date)
+
+    # Initialize animated stuff
+    line_kwargs = dict(x=starttime.matplotlib_date, color='forestgreen', linewidth=1)
+    spec_line = spec_ax.axvline(**line_kwargs)
+    wf_line = wf_ax.axvline(ymin=0.01, clip_on=False, zorder=10, **line_kwargs)
+    time_box = AnchoredText(
+        s=starttime.strftime('%H:%M:%S'),
+        pad=0.2,
+        loc='lower right',
+        bbox_to_anchor=[1, 1],
+        bbox_transform=wf_ax.transAxes,
+        borderpad=0,
+        prop=dict(color='forestgreen'),
+    )
+    offset_px = -0.0025 * RESOLUTIONS[resolution][1]  # Resolution-independent!
+    time_box.txt._text.set_y(offset_px)  # [pixels] Vertically center text
+    time_box.zorder = 12  # This should place it on the very top; see below
+    time_box.patch.set_linewidth(matplotlib.rcParams['axes.linewidth'])
+    wf_ax.add_artist(time_box)
+
+    # Adjustments to ensure time marker line is zordered properly
+    # 9 is below marker; 11 is above marker
+    spec_ax.spines['bottom'].set_zorder(9)
+    wf_ax.spines['top'].set_zorder(9)
+    for side in 'bottom', 'left', 'right':
+        wf_ax.spines[side].set_zorder(11)
+
+    # Pick smart limits rounded to nearest 10
+    if db_lim == 'smart':
+        db_min = np.percentile(sxx_db, 20)
+        db_max = sxx_db.max()
+        db_lim = (np.ceil(db_min / 10) * 10, np.floor(db_max / 10) * 10)
+
+    # Clip image to db_lim if provided (doesn't clip if db_lim=None)
+    im.set_clim(db_lim)
+
+    # Automatically determine whether to show triangle extensions on colorbar
+    # (kind of adopted from xarray)
+    if db_lim:
+        min_extend = sxx_db.min() < db_lim[0]
+        max_extend = sxx_db.max() > db_lim[1]
+    else:
+        min_extend = False
+        max_extend = False
+    if min_extend and max_extend:
+        extend = 'both'
+    elif min_extend:
+        extend = 'min'
+    elif max_extend:
+        extend = 'max'
+    else:
+        extend = 'neither'
+
+    fig.colorbar(im, cax, extend=extend, extendfrac=EXTENDFRAC, label=clab)
+
+    spec_ax.set_title(tr.id, family='JetBrains Mono')
+
+    fig.tight_layout()
+    fig.subplots_adjust(hspace=0, wspace=0.05)
+
+    # Finnicky formatting to get extension triangles (if they exist) to extend
+    # above and below the vertical extent of the spectrogram axes
+    pos = cax.get_position()
+    triangle_height = EXTENDFRAC * pos.height
+    ymin = pos.ymin
+    height = pos.height
+    if min_extend and max_extend:
+        ymin -= triangle_height
+        height += 2 * triangle_height
+    elif min_extend and not max_extend:
+        ymin -= triangle_height
+        height += triangle_height
+    elif max_extend and not min_extend:
+        height += triangle_height
+    else:
+        pass
+    cax.set_position([pos.xmin, ymin, pos.width, height])
+
+    # Move offset text around and format it more nicely, see
+    # https://github.com/matplotlib/matplotlib/blob/710fce3df95e22701bd68bf6af2c8adbc9d67a79/lib/matplotlib/ticker.py#L677
+    magnitude = wf_ax.yaxis.get_major_formatter().orderOfMagnitude
+    if magnitude:  # I.e., if offset text is present
+        wf_ax.yaxis.get_offset_text().set_visible(False)  # Remove original text
+        sf = ScalarFormatter(useMathText=True)
+        sf.orderOfMagnitude = magnitude  # Formatter needs to know this!
+        sf.locs = [47]  # Can't be an empty list
+        wf_ax.text(
+            0.002,
+            0.95,
+            sf.get_offset(),  # Let the ScalarFormatter do the formatting work
+            transform=wf_ax.transAxes,
+            ha='left',
+            va='top',
+        )
+
+    return fig, spec_line, wf_line, time_box, wf_progress
+
+
+def _ffmpeg_combine(audio_file, video_file, output_file, call_str):
+    """
+    Combine audio and video files into a single movie. Uses a system call to
+    `FFmpeg`_.
+
+    Args:
+        audio_file (:class:`~pathlib.Path`): Audio file to use
+        video_file (:class:`~pathlib.Path`): Video file to use
+        output_file (:class:`~pathlib.Path`): Output file (full path)
+        call_str (str): Formatted record of sonify call to add to metadata
+
+    .. _FFmpeg: https://www.ffmpeg.org/
+    """
+
+    args = [
+        'ffmpeg',
+        '-y',
+        '-v',
+        'warning',
+        '-i',
+        video_file,
+        '-guess_layout_max',
+        '0',
+        '-i',
+        audio_file,
+        '-c:v',
+        'copy',
+        '-c:a',
+        'aac',
+        '-b:a',
+        '320k',
+        '-ac',
+        '2',
+        '-metadata',
+        f'artist=sonify, rev. {__version__}',
+        '-metadata',
+        f'comment={call_str}',
+        output_file,
+    ]
+    print('Combining video and audio using FFmpeg...')
+    code = subprocess.call(args)
+
+    if code == 0:
+        print(f'Video saved as {output_file}')
+    else:
+        output_file.unlink(missing_ok=True)  # Remove file if it was made
+        raise OSError(
+            'Issue with FFmpeg conversion. Check error messages and try again.'
+        )
+
+
+# Subclass ConciseDateFormatter (modifies __init__() and set_axis() methods)
+class _UTCDateFormatter(mdates.ConciseDateFormatter):
+    def __init__(self, locator, is_local_time):
+        super().__init__(locator)
+
+        # Determine proper time label (local time or UTC)
+        if is_local_time:
+            time_type = 'Local'
+        else:
+            time_type = 'UTC'
+
+        # Re-format datetimes
+        self.formats[1] = '%B'
+        self.zero_formats[2:4] = ['%B', '%B %d']
+        self.offset_formats = [
+            f'{time_type} time',
+            f'{time_type} time in %Y',
+            f'{time_type} time in %B %Y',
+            f'{time_type} time on %B %d, %Y',
+            f'{time_type} time on %B %d, %Y',
+            f'{time_type} time on %B %d, %Y at %H:%M',
+        ]
+
+    def set_axis(self, axis):
+        self.axis = axis
+
+        # If this is an x-axis (usually is!) then center the offset text
+        if self.axis.axis_name == 'x':
+            offset = self.axis.get_offset_text()
+            offset.set_horizontalalignment('center')
+            offset.set_x(0.5)
+
+
+def main():
+    """
+    This function is run when ``sonify.py`` is called as a script. It's also set
+    up as an entry point.
+    """
+
+    parser = argparse.ArgumentParser(
+        description='Produce an animated spectrogram with a soundtrack derived from sped-up seismic or infrasound data.',
+        allow_abbrev=False,
+    )
+
+    # Hack the printing function of the parser to fix --db_lim option formatting
+    def _print_message_replace(self, message, file=None):
+        if message:
+            if file is None:
+                file = _sys.stderr
+            file.write(message.replace('[DB_LIM ...]', '[DB_LIM]'))
+
+    parser._print_message = MethodType(_print_message_replace, parser)
+
+    parser.add_argument(
+        '-v',
+        '--version',
+        action='version',
+        version=f'{parser.prog}, rev. {__version__}',
+        help=f'show revision number and exit',
+    )
+
+    parser.add_argument('network', help='SEED network code')
+    parser.add_argument('station', help='SEED station code')
+    parser.add_argument('channel', help='SEED channel code')
+    parser.add_argument(
+        'starttime',
+        type=UTCDateTime,
+        help='start time of animation (UTC), format yyyy-mm-ddThh:mm:ss',
+    )
+    parser.add_argument(
+        'endtime',
+        type=UTCDateTime,
+        help='end time of animation (UTC), format yyyy-mm-ddThh:mm:ss',
+    )
+    parser.add_argument('--location', default='*', help='SEED location code')
+    parser.add_argument(
+        '--freqmin',
+        default=None,
+        type=float,
+        help='lower bandpass corner [Hz] (defaults to 20 Hz / "SPEED_UP_FACTOR")',
+    )
+    parser.add_argument(
+        '--freqmax',
+        default=None,
+        type=float,
+        help='upper bandpass corner [Hz] (defaults to 20,000 Hz / "SPEED_UP_FACTOR" or the Nyquist frequency, whichever is smaller)',
+    )
+    parser.add_argument(
+        '--speed_up_factor',
+        default=200,
+        type=int,
+        help='factor by which to speed up the waveform data (higher values = higher pitches)',
+    )
+    parser.add_argument(
+        '--fps', default=1, type=int, help='frames per second of output video'
+    )
+    parser.add_argument(
+        '--resolution',
+        default='4K',
+        choices=RESOLUTIONS.keys(),
+        help='resolution of output video; one of "crude" (640 x 360), "720p" (1280 x 720), "1080p" (1920 x 1080), "2K" (2560 x 1440), or "4K" (3840 x 2160)',
+    )
+    parser.add_argument(
+        '--output_dir',
+        default=None,
+        help='directory where output video should be saved (defaults to current working directory)',
+    )
+    parser.add_argument(
+        '--spec_win_dur',
+        default=5,
+        type=float,
+        help='duration of spectrogram window [s]',
+    )
+    parser.add_argument(
+        '--db_lim',
+        default='smart',
+        nargs='+',
+        help='numbers "<min>" "<max>" defining min and max colormap cutoffs [dB], "smart" for a sensible automatic choice, or "None" for no clipping',
+    )
+    parser.add_argument(
+        '--log',
+        action='store_true',
+        help='use log scaling for y-axis of spectrogram',
+    )
+    parser.add_argument(
+        '--utc_offset',
+        default=None,
+        type=float,
+        help='if provided, convert UTC time to local time using this offset [hours] before plotting',
+    )
+
+    input_args = parser.parse_args()
+
+    # Extra type check for db_lim kwarg
+    db_lim_error = False
+    db_lim = np.atleast_1d(input_args.db_lim)
+    if db_lim.size == 1:
+        db_lim = db_lim[0]
+        if db_lim == 'smart':
+            pass
+        elif db_lim == 'None':
+            db_lim = None
+        else:
+            db_lim_error = True
+    elif db_lim.size == 2:
+        try:
+            db_lim = tuple(float(s) for s in db_lim)
+        except ValueError:
+            db_lim_error = True
+    else:  # User provided more than 2 args
+        db_lim_error = True
+    if db_lim_error:
+        parser.error(
+            'argument --db_lim: must be one of "smart", "None", or two numeric values "<min>" "<max>"'
+        )
+
+    sonify(
+        input_args.network,
+        input_args.station,
+        input_args.channel,
+        input_args.starttime,
+        input_args.endtime,
+        input_args.location,
+        input_args.freqmin,
+        input_args.freqmax,
+        input_args.speed_up_factor,
+        input_args.fps,
+        input_args.resolution,
+        input_args.output_dir,
+        input_args.spec_win_dur,
+        db_lim,
+        input_args.log,
+        input_args.utc_offset,
+    )
+
+
+if __name__ == '__main__':
+    main()