#!/usr/bin/env python3

'''
Generate samples.
'''

import os
import sys
import traceback
import struct
import random
from utils import log, err


def to_bytes(f):
    return struct.pack('h', int(f * (2**15 - 1)))

def gen(fun, samplerate, volume):
    t = 0
    while True:
        for i in range(samplerate):
            j = i / samplerate
            yield to_bytes(fun(j, t) * volume)
            t += 1

def parse_arg(s):
    try:
        key, val = s.split('=', 1)
    except ValueError:
        return {s: True}

    funs = {
        'seed': int,
        'samplerate': float,
        'volume': int
    }
    conv = funs.get(key, lambda x: x)
    return {key: conv(val)}

def import_module(name):
    # This is the easiest way.
    exec('import {}'.format(name))
    return eval(name)

class FunctionGetterDict(dict):
    '''
    'get' is lazy with its fallback parameter.
    '''
    def get(self, key, f=None):
        try:
            return self.__getitem__(key)
        except KeyError:
            try:
                return f()
            except TypeError:
                return f

def main(*args):
    rargs = []
    settings = FunctionGetterDict()
    for arg in args:
        if arg.startswith('-'):
            settings.update(parse_arg(arg.lstrip('-')))
        else:
            rargs.append(arg)

    if settings.get('quiet'):
        sys.stderr = open(os.devnull, 'w')

    if settings.get('version'):
        log(get_version())
        return 0

    try:
        module_fun = rargs[0]
    except IndexError:
        if settings.get('help'):
            log(get_help())
            return 0
        else:
            err('missing module and function name')
            return 1

    try:
        module_name, fun_name = module_fun.split('.', 1)
    except ValueError:
        try:
            module = import_module(module_fun)
        except ImportError:
            err('module {} invalid'.format(repr(module_fun)))
            return 1
        if settings.get('help'):
            log(get_help(module, just_module=True))
            return 0
        else:
            err('missing function name')
            return 1

    try:
        module = import_module(module_name)
    except ImportError:
        err('module {} invalid'.format(repr(module_name)))
        return 1

    try:
        fun_gen = module.__getattribute__(fun_name)
    except AttributeError:
        err('function {} does not exist'.format(
            repr(module_name + '.' + fun_name)))
        return 1

    fun_args = rargs[1:]

    if not 'seed' in settings:
        settings['seed'] = random.getrandbits(64)
    seed = settings['seed']
    log('seed is:', seed)
    random.seed(seed)

    if settings.get('help'):
        log(get_help(fun_gen))
        return 0

    if not 'samplerate' in settings:
        settings['samplerate'] = 44100
    if not 'volume' in settings:
        settings['volume'] = 1

    try:
        fun = fun_gen(*fun_args, **settings)
    except TypeError as e:
        err(e)
        return 1

    try:
        with open(1, 'wb') as out:
            for some_bytes in gen(fun, settings['samplerate'], settings['volume']):
                out.write(some_bytes)
    except (KeyboardInterrupt, BrokenPipeError):
        pass
    except:
        traceback.print_exc()
    finally:
        return 0

def get_version():
    return '''\
sample: generate raw sound data with Python 3
version: git
website: http://metanohi.name/projects/sample
license: WTFPL 2.0
'''.rstrip()

def get_help(fun=None, just_module=False):
    if fun is None:
        return '''\
sample: generate raw sound data with Python 3

Usage: sample [<option>...] <module>.<function> [<arg>...]

'sample' is called with optional settings, a required module function name, and
possibly arguments for that function.  When 'sample' is run, it does two things:
  + outputs a signed 16 bits little endian mono byte stream to standard out
  + prints details and errors to standard error

NOTE: 'sample' does not use numpy or any external Python library, so it's slower
than it needs to be.  On the upside, you don't have to read up on any library
documentation.

NOTE: 'sample' is naive w.r.t. its command-line input, so don't make others run
the program through your user, as they can probably break out quite easily.

An option starts with a '-' character and does one of two things:
  + assigns a value to a key: '-<key>=<value>'
  + sets a key to be true: '-<key>'

Some options are global, while some may only affect certain functions.  The
global options are:

  -seed=<int>    Set the seed for the random generator.  This enables
                 reproducibility for otherwise random functions.  The default
                 is to let Python pick one.
  -samplerate=<int>    Set the number of samples per second.  The default is
                       44100.
  -volume=<float>    Set the global volume; must be a number between 0 and 1.
                     The default is 1.
  -quiet    Do not log anything.
  -help    Print help for a function.  If only the module is given ('<module>'
           instead of '<module>.<function>', try to find all applicable
           functions for that module, and print the help text for each function.
           This is done by looking for a '__sample_functions__' variable
           containing functions.  If no function or module is given, print this
           very help text.  Exit after printing.
  -version    Print short version information and exit.

Apart from 'sample', you can also use these scripts (which all have external
dependencies; see the scripts for full documentation):
  + 'play': sends sound data to your speaker.  Works independently of 'sample'.
  + 'save': records to a file.  Works independently of 'sample'.
  + 'sampleplay': uses 'play', but with the arguments for 'sample'.
  + 'samplesave': uses 'save', but with the arguments for 'sample'.

'sample' is distributed with a small module called 'base' of simple primitives.

Examples:

  Make a sine curve of 600 Hz and play it:
    sampleplay base.sin 600

  Save that same curve to a file:
    samplesave music.wav base.sin 600

  Find out which functions are available in the 'base' module:
    sample -help base

  Check out the details of the sine function:
    sample -help base.sin

  Make three overlapping sines (900 Hz, 95 Hz, and 380 Hz) and play them:
    sampleplay base.sins 900 95 380

  Play 5 sine curves with random frequencies, phases, and amplitudes:
    sampleplay base.random_sins 5

  Do the same, but use a seed for reproducibility's sake:
    sampleplay -seed=12123639752121693971 base.random_sins 5
'''.rstrip()
    else:
        # It's mostly the same code even if just_module is true.
        name = fun.__name__
        doc = fun.__doc__
        if doc is None:
            return '{}: no documentation'.format(repr(name))
        else:
            doc = doc.strip()
            text = '{}:\n{}'.format(repr(name), doc)

            if not just_module:
                return text
            else:
                module = fun
                try:
                    funs = module.__getattribute__('__sample_functions__')
                except AttributeError:
                    text += '\n\nNo public functions.'
                    return text
                text += '\n\nAvailable functions:'
                for fun in funs:
                    text += '\n\n{}'.format(get_help(fun))
                return text

if __name__ == '__main__':
    sys.exit(main(*sys.argv[1:]))