15.1.927. tablet_qt/tools/chord.py

#!/usr/bin/env python

"""
tools/chord.py

===============================================================================

    Copyright (C) 2012, University of Cambridge, Department of Psychiatry.
    Created by Rudolf Cardinal (rnc1001@cam.ac.uk).

    This file is part of CamCOPS.

    CamCOPS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    CamCOPS is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with CamCOPS. If not, see <https://www.gnu.org/licenses/>.

===============================================================================

**Generate musical chords.**

"""

# http://stackoverflow.com/questions/5173795/how-can-i-generate-a-note-or-chord-in-python  # noqa
# ... and me
# http://blogs.msdn.com/b/dawate/archive/2009/06/23/intro-to-audio-programming-part-2-demystifying-the-wav-format.aspx  # noqa
# ... for 16-bit WAV (sampwidth = 2), data range = -32760 to +32760

import math
import struct
from typing import List, Tuple
import wave

DEBUG = True


def synth_complex(
    freq_coefs: List[Tuple[float, float]] = None,
    duration_s: float = 1.0,
    filename: str = "test.wav",
    frate_hz: float = 44100.00,
    amp_proportion: float = 1.0,
) -> None:
    """

    Args:
        freq_coefs: list of tuples of (frequency in Hz, intensity)
        ... where "intensity" is the fraction of the total intensity to give
            each note. Typically, for 3 notes, use 1/3 per note (etc.).
        duration_s: overall duration
        filename: filename to save
        frate_hz: frame (sampling) rate, Hz
        amp_proportion: proportion of maximum amplitude (usual range 0-1)

    Returns:
        None
    """
    if freq_coefs is None:
        freq_coefs = [(440, 1)]  # type: List[Tuple[float, float]]
    sine_list = []
    datasize = int(frate_hz * duration_s)
    clipped = False
    for x in range(datasize):
        samp = 0
        for k in range(len(freq_coefs)):
            freq = freq_coefs[k][0]
            coef = freq_coefs[k][1]
            samp += coef * math.sin(2 * math.pi * freq * (x / frate_hz))
            # each component can contribute (+/- coef) to each sample
        if samp > 1 or samp < -1:
            clipped = True
        samp = min(max(samp, -1), 1)
        sine_list.append(samp)
    wav_file = wave.open(filename, "w")
    nchannels = 1
    sampwidth = 2
    maxamp = 32760  # as above
    framerate = int(frate_hz)
    nframes = datasize
    comptype = "NONE"
    compname = "not compressed"
    wav_file.setparams(
        (nchannels, sampwidth, framerate, nframes, comptype, compname)
    )
    ampfactor = amp_proportion * maxamp
    print("writing", filename)
    for s in sine_list:
        wav_file.writeframes(struct.pack("h", int(s * ampfactor)))
    wav_file.close()
    if clipped:
        print("warning: amplitude CLIPPED")


def frequency_hz(note: str, octave: int = 4) -> float:
    """
    Returns a frequency from a note name.
    """
    badnote = "bad note"
    if (
        not note
        or not isinstance(note, str)
        or len(note) > 2
        or not isinstance(octave, int)
    ):
        raise Exception(badnote)
    basenote = note[0].upper()
    # notenum = 0  # will be: semitones relative to reference A (A4)
    if basenote == "C":
        notenum = -9
    elif basenote == "D":
        notenum = -7
    elif basenote == "E":
        notenum = -5
    elif basenote == "F":
        notenum = -4
    elif basenote == "G":
        notenum = -2
    elif basenote == "A":
        notenum = 0
    elif basenote == "B":
        notenum = 2
    else:
        raise Exception(badnote)
    if len(note) == 2:
        modifier = note[1]
        if modifier == "#":  # sharp
            notenum += 1
        elif modifier == "b":  # flat
            notenum -= 1
        else:
            raise Exception(badnote)
    notenum += 12 * (octave - 4)
    # Frequency = (twelfth root of 2) ^ note * 440 Hz
    # Frequency = 2 ^ (note / 12) * 440 Hz
    freq_hz = pow(2, notenum / 12.0) * 440.0
    if DEBUG:
        print(note, octave, " = ", freq_hz, " Hz")
    return freq_hz
    # Test case: frequencyHz("A", 4) should return 440 (pitch standard)
    # Test case: frequencyHz("C", 4) should return 261.626 (middle C)
    # http://en.wikipedia.org/wiki/Scientific_pitch_notation


# noinspection PyPep8Naming
def ided3d() -> None:
    """
    Creates chords for the ID/ED-3D task.
    """
    A4 = frequency_hz("A", 4)
    # C4 = frequency_hz("C", 4)
    C5 = frequency_hz("C", 5)
    Eb5 = frequency_hz("Eb", 5)
    E5 = frequency_hz("E", 5)
    Fs5 = frequency_hz("F#", 5)
    G5 = frequency_hz("G", 5)
    C6 = frequency_hz("C", 6)

    synth_complex(
        [(E5, 1 / 3), (G5, 1 / 3), (C6, 1 / 3)],
        duration_s=0.164,
        filename="correct.wav",
    )
    synth_complex(
        [(A4, 1 / 4), (C5, 1 / 4), (Eb5, 1 / 4), (Fs5, 1 / 4)],
        duration_s=0.550,
        filename="incorrect.wav",
    )


if __name__ == "__main__":
    ided3d()