!pip install numpy matplotlib ipython librosa ipywidgets
Requirement already satisfied: numpy in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (2.1.3)
Requirement already satisfied: matplotlib in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (3.10.1)
Requirement already satisfied: ipython in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (9.0.2)
Requirement already satisfied: librosa in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (0.11.0)
Requirement already satisfied: ipywidgets in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (8.1.5)
Requirement already satisfied: contourpy>=1.0.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (1.3.1)
Requirement already satisfied: cycler>=0.10 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (0.12.1)
Requirement already satisfied: fonttools>=4.22.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (4.56.0)
Requirement already satisfied: kiwisolver>=1.3.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (1.4.8)
Requirement already satisfied: packaging>=20.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (24.2)
Requirement already satisfied: pillow>=8 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (11.1.0)
Requirement already satisfied: pyparsing>=2.3.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (3.2.3)
Requirement already satisfied: python-dateutil>=2.7 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from matplotlib) (2.9.0.post0)
Requirement already satisfied: decorator in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (5.2.1)
Requirement already satisfied: ipython-pygments-lexers in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (1.1.1)
Requirement already satisfied: jedi>=0.16 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (0.19.2)
Requirement already satisfied: matplotlib-inline in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (0.1.7)
Requirement already satisfied: pexpect>4.3 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (4.9.0)
Requirement already satisfied: prompt_toolkit<3.1.0,>=3.0.41 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (3.0.50)
Requirement already satisfied: pygments>=2.4.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (2.19.1)
Requirement already satisfied: stack_data in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (0.6.3)
Requirement already satisfied: traitlets>=5.13.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (5.14.3)
Requirement already satisfied: typing_extensions>=4.6 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipython) (4.13.0)
Requirement already satisfied: audioread>=2.1.9 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (3.0.1)
Requirement already satisfied: numba>=0.51.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (0.61.0)
Requirement already satisfied: scipy>=1.6.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (1.15.2)
Requirement already satisfied: scikit-learn>=1.1.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (1.6.1)
Requirement already satisfied: joblib>=1.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (1.4.2)
Requirement already satisfied: soundfile>=0.12.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (0.13.1)
Requirement already satisfied: pooch>=1.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (1.8.2)
Requirement already satisfied: soxr>=0.3.2 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (0.5.0.post1)
Requirement already satisfied: lazy_loader>=0.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (0.4)
Requirement already satisfied: msgpack>=1.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from librosa) (1.1.0)
Requirement already satisfied: comm>=0.1.3 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipywidgets) (0.2.2)
Requirement already satisfied: widgetsnbextension~=4.0.12 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipywidgets) (4.0.13)
Requirement already satisfied: jupyterlab-widgets~=3.0.12 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from ipywidgets) (3.0.13)
Requirement already satisfied: parso<0.9.0,>=0.8.4 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from jedi>=0.16->ipython) (0.8.4)
Requirement already satisfied: llvmlite<0.45,>=0.44.0dev0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from numba>=0.51.0->librosa) (0.44.0)
Requirement already satisfied: ptyprocess>=0.5 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from pexpect>4.3->ipython) (0.7.0)
Requirement already satisfied: platformdirs>=2.5.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from pooch>=1.1->librosa) (4.3.7)
Requirement already satisfied: requests>=2.19.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from pooch>=1.1->librosa) (2.32.3)
Requirement already satisfied: wcwidth in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from prompt_toolkit<3.1.0,>=3.0.41->ipython) (0.2.13)
Requirement already satisfied: six>=1.5 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from python-dateutil>=2.7->matplotlib) (1.17.0)
Requirement already satisfied: threadpoolctl>=3.1.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from scikit-learn>=1.1.0->librosa) (3.6.0)
Requirement already satisfied: cffi>=1.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from soundfile>=0.12.1->librosa) (1.17.1)
Requirement already satisfied: executing>=1.2.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from stack_data->ipython) (2.2.0)
Requirement already satisfied: asttokens>=2.1.0 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from stack_data->ipython) (3.0.0)
Requirement already satisfied: pure-eval in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from stack_data->ipython) (0.2.3)
Requirement already satisfied: pycparser in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from cffi>=1.0->soundfile>=0.12.1->librosa) (2.22)
Requirement already satisfied: charset-normalizer<4,>=2 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (3.4.1)
Requirement already satisfied: idna<4,>=2.5 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (3.10)
Requirement already satisfied: urllib3<3,>=1.21.1 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (2.3.0)
Requirement already satisfied: certifi>=2017.4.17 in /Users/bmachado/blog/.venv/lib/python3.11/site-packages (from requests>=2.19.0->pooch>=1.1->librosa) (2025.1.31)

[notice] A new release of pip is available: 23.2.1 -> 25.0.1
[notice] To update, run: pip install --upgrade pip
import numpy as np
import matplotlib.pyplot as plt
from IPython.display import Audio, display
import librosa
import librosa.display

# Define parameters
sample_rate = 44100  # samples per second
duration = 1.0       # seconds
t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)

# Function to generate a pure sine wave
def generate_sine_wave(freq, amplitude=1.0):
    return amplitude * np.sin(2 * np.pi * freq * t)

# Function to generate a sawtooth wave
def generate_sawtooth_wave(freq, amplitude=1.0):
    return amplitude * 2 * (freq * t - np.floor(0.5 + freq * t))

# Function to generate a square wave
def generate_square_wave(freq, amplitude=1.0):
    return amplitude * np.sign(np.sin(2 * np.pi * freq * t))

# Function to generate a triangle wave
def generate_triangle_wave(freq, amplitude=1.0):
    return amplitude * 2 * np.abs(2 * (freq * t - np.floor(0.5 + freq * t))) - 1

# Function to plot one period of a wave
def plot_one_period(wave_func, freq, title):
    # Generate the wave
    wave = wave_func(freq)
    
    # Calculate the period in seconds
    period = 1.0 / freq
    
    # Calculate how many samples make up one period
    samples_per_period = int(period * sample_rate)
    
    # Plot one period
    plt.figure(figsize=(10, 4))
    plt.plot(t[:samples_per_period], wave[:samples_per_period])
    plt.title(f"One Period of {title} Wave at {freq} Hz")
    plt.xlabel("Time (s)")
    plt.ylabel("Amplitude")
    plt.grid(True)
    plt.show()
    
    # Return the full wave for audio playback
    return wave

# Generate and plot different waveforms at the same frequency
freq = 440  # A4 note

print("In music, 'color' or 'timbre' refers to the quality of a sound that distinguishes different types of sound production.")
print("It's what makes a piano sound different from a violin, even when playing the same note.")
print("This is primarily determined by the harmonic content of the sound - the presence and strength of overtones.")
print("\nBelow are examples of different waveforms playing the same note (A4 = 440 Hz) but with different timbres:")

# Sine wave - pure tone with no harmonics
sine_wave = plot_one_period(generate_sine_wave, freq, "Sine")
print("Sine Wave: The purest tone with no harmonics, sounds smooth and clear.")
display(Audio(sine_wave, rate=sample_rate))

# Sawtooth wave - rich in harmonics
sawtooth_wave = plot_one_period(generate_sawtooth_wave, freq, "Sawtooth")
print("Sawtooth Wave: Rich in harmonics, sounds bright and buzzy like brass instruments.")
display(Audio(sawtooth_wave, rate=sample_rate))

# Square wave - contains only odd harmonics
square_wave = plot_one_period(generate_square_wave, freq, "Square")
print("Square Wave: Contains only odd harmonics, sounds hollow like a clarinet.")
display(Audio(square_wave, rate=sample_rate))

# Triangle wave - contains odd harmonics that decrease more rapidly
triangle_wave = plot_one_period(generate_triangle_wave, freq, "Triangle")
print("Triangle Wave: Contains odd harmonics that decrease more rapidly, sounds softer than square wave.")
display(Audio(triangle_wave, rate=sample_rate))

# Visualize the frequency spectrum (harmonics) of each waveform
plt.figure(figsize=(12, 10))

# Function to plot the spectrum
def plot_spectrum(wave, title, position):
    plt.subplot(4, 1, position)
    D = librosa.amplitude_to_db(np.abs(librosa.stft(wave)), ref=np.max)
    librosa.display.specshow(D, y_axis='log', x_axis='time', sr=sample_rate)
    plt.title(f"Frequency Spectrum of {title} Wave")
    plt.colorbar(format='%+2.0f dB')

plot_spectrum(sine_wave, "Sine", 1)
plot_spectrum(sawtooth_wave, "Sawtooth", 2)
plot_spectrum(square_wave, "Square", 3)
plot_spectrum(triangle_wave, "Triangle", 4)

plt.tight_layout()
plt.show()

print("\nThe 'color' in music comes from these different harmonic structures.")
print("Real instruments produce complex waveforms with unique harmonic signatures.")
print("This is why a piano and a guitar sound different even when playing the same note.")
In music, 'color' or 'timbre' refers to the quality of a sound that distinguishes different types of sound production.
It's what makes a piano sound different from a violin, even when playing the same note.
This is primarily determined by the harmonic content of the sound - the presence and strength of overtones.

Below are examples of different waveforms playing the same note (A4 = 440 Hz) but with different timbres:
Sine Wave: The purest tone with no harmonics, sounds smooth and clear.
Sawtooth Wave: Rich in harmonics, sounds bright and buzzy like brass instruments.
Square Wave: Contains only odd harmonics, sounds hollow like a clarinet.
Triangle Wave: Contains odd harmonics that decrease more rapidly, sounds softer than square wave.
The 'color' in music comes from these different harmonic structures.
Real instruments produce complex waveforms with unique harmonic signatures.
This is why a piano and a guitar sound different even when playing the same note.
# Let's explore some key musical concepts and how they appear in waveforms

# 1. Amplitude (Volume/Loudness)
print("### 1. Amplitude")
print("Amplitude represents the volume or loudness of a sound. In the waveform, it's shown by the height of the wave from the center line.")
print("- **Higher amplitude** = louder sound")
print("- **Lower amplitude** = quieter sound")

# Create two identical notes with different amplitudes
freq = 440  # A4 note
quiet_sound = generate_sine_wave(freq, amplitude=0.3)
loud_sound = generate_sine_wave(freq, amplitude=0.9)

# Plot amplitude comparison
plt.figure(figsize=(15, 3))
plt.subplot(1, 2, 1)
plt.plot(t[:1000], quiet_sound[:1000])
plt.title("Quiet Sound (Low Amplitude)")
plt.ylim(-1, 1)

plt.subplot(1, 2, 2)
plt.plot(t[:1000], loud_sound[:1000])
plt.title("Loud Sound (High Amplitude)")
plt.ylim(-1, 1)

plt.suptitle("1. Amplitude Comparison", fontsize=16)
plt.tight_layout()
plt.show()

# Play the amplitude comparison sounds
print("Quiet Sound:")
display(Audio(quiet_sound, rate=sample_rate))
print("Loud Sound:")
display(Audio(loud_sound, rate=sample_rate))

# 2. Frequency (Pitch)
print("### 2. Frequency")
print("Frequency determines the pitch of a sound, measured in Hertz (Hz).")
print("- **Higher frequency** = higher pitch (more wave cycles per second)")
print("- **Lower frequency** = lower pitch (fewer wave cycles per second)")
print("- Each octave represents a doubling/halving of frequency")

# Create two notes with the same amplitude but different frequencies
low_note = generate_sine_wave(220)  # A3 (one octave lower)
high_note = generate_sine_wave(880)  # A5 (one octave higher)

# Plot frequency comparison
plt.figure(figsize=(15, 3))
plt.subplot(1, 2, 1)
plt.plot(t[:1000], low_note[:1000])
plt.title("Low Note (220 Hz)")
plt.ylim(-1, 1)

plt.subplot(1, 2, 2)
plt.plot(t[:1000], high_note[:1000])
plt.title("High Note (880 Hz)")
plt.ylim(-1, 1)

plt.suptitle("2. Frequency Comparison", fontsize=16)
plt.tight_layout()
plt.show()

# Play the frequency comparison sounds
print("Low Note (220 Hz):")
display(Audio(low_note, rate=sample_rate))
print("High Note (880 Hz):")
display(Audio(high_note, rate=sample_rate))

# 3. Harmonics (Overtones)
print("### 3. Harmonics (Overtones)")
print("Harmonics are additional frequencies that occur naturally in most musical sounds.")
print("- **Pure tone** = single frequency (sine wave)")
print("- **Complex tone** = fundamental frequency plus harmonics")
print("- Harmonics give instruments their distinctive timbre or \"tone color\"")
print("- The pattern and strength of harmonics is why a piano and violin sound different even playing the same note")

# Create a pure tone vs a tone with harmonics
pure_tone = generate_sine_wave(440)
# Create a complex tone with fundamental and harmonics
complex_tone = generate_sine_wave(440, 0.8) + \
               generate_sine_wave(880, 0.4) + \
               generate_sine_wave(1320, 0.2) + \
               generate_sine_wave(1760, 0.1)
complex_tone = complex_tone / np.max(np.abs(complex_tone))  # Normalize

# Plot harmonics comparison
plt.figure(figsize=(15, 3))
plt.subplot(1, 2, 1)
plt.plot(t[:1000], pure_tone[:1000])
plt.title("Pure Tone (Single Frequency)")
plt.ylim(-1, 1)

plt.subplot(1, 2, 2)
plt.plot(t[:1000], complex_tone[:1000])
plt.title("Complex Tone (With Harmonics)")
plt.ylim(-1, 1)

plt.suptitle("3. Harmonics Comparison", fontsize=16)
plt.tight_layout()
plt.show()

# Play the harmonics comparison sounds
print("Pure Tone:")
display(Audio(pure_tone, rate=sample_rate))
print("Complex Tone with Harmonics:")
display(Audio(complex_tone, rate=sample_rate))

# 4. Envelope (ADSR: Attack, Decay, Sustain, Release)
print("### 4. Envelope (ADSR)")
print("The envelope describes how a sound changes in amplitude over time:")
print("- **Attack**: How quickly the sound reaches full volume")
print("- **Decay**: The initial fall in volume after the attack")
print("- **Sustain**: The steady volume level maintained while a note is held")
print("- **Release**: How quickly the sound fades after the note ends")
print("")
print("Different instruments have characteristic envelopes:")
print("- Piano: Fast attack, no sustain (immediate decay)")
print("- Violin: Variable attack, can sustain indefinitely while bowing")
print("- Percussion: Very fast attack, short decay, no sustain")

# Use a lower frequency so we can see the actual wave in the envelope
freq = 100  # Very low frequency to see individual cycles
time_points = np.linspace(0, 1, len(t))

# Create an ADSR envelope
attack = 0.1
decay = 0.2
sustain_level = 0.7
release = 0.3

envelope = np.ones_like(time_points)
# Attack phase
attack_end = int(attack * len(time_points))
envelope[:attack_end] = np.linspace(0, 1, attack_end)
# Decay phase
decay_end = int((attack + decay) * len(time_points))
envelope[attack_end:decay_end] = np.linspace(1, sustain_level, decay_end - attack_end)
# Sustain phase
release_start = int((1 - release) * len(time_points))
# Release phase
envelope[release_start:] = np.linspace(sustain_level, 0, len(time_points) - release_start)

# Apply envelope to a tone
flat_tone = generate_sine_wave(freq)
shaped_tone = flat_tone * envelope

# Plot envelope comparison
plt.figure(figsize=(15, 9))
plt.subplot(3, 1, 1)
plt.plot(t, flat_tone)
plt.title("Original Tone (No Envelope)")
plt.ylim(-1, 1)

plt.subplot(3, 1, 2)
plt.plot(t, envelope)
plt.title("ADSR Envelope")
plt.ylim(0, 1.1)

plt.subplot(3, 1, 3)
plt.plot(t, shaped_tone)
plt.title("Shaped Tone (Original Wave × Envelope)")
plt.ylim(-1, 1)

plt.suptitle("4. Envelope Comparison", fontsize=16)
plt.tight_layout()
plt.show()

# Play the envelope comparison sounds
print("Flat Tone (No Envelope):")
display(Audio(flat_tone, rate=sample_rate))
print("Shaped Tone (With ADSR Envelope):")
display(Audio(shaped_tone, rate=sample_rate))
### 1. Amplitude
Amplitude represents the volume or loudness of a sound. In the waveform, it's shown by the height of the wave from the center line.
- **Higher amplitude** = louder sound
- **Lower amplitude** = quieter sound
Quiet Sound:
Loud Sound:
### 2. Frequency
Frequency determines the pitch of a sound, measured in Hertz (Hz).
- **Higher frequency** = higher pitch (more wave cycles per second)
- **Lower frequency** = lower pitch (fewer wave cycles per second)
- Each octave represents a doubling/halving of frequency
Low Note (220 Hz):
High Note (880 Hz):
### 3. Harmonics (Overtones)
Harmonics are additional frequencies that occur naturally in most musical sounds.
- **Pure tone** = single frequency (sine wave)
- **Complex tone** = fundamental frequency plus harmonics
- Harmonics give instruments their distinctive timbre or "tone color"
- The pattern and strength of harmonics is why a piano and violin sound different even playing the same note
Pure Tone:
Complex Tone with Harmonics:
### 4. Envelope (ADSR)
The envelope describes how a sound changes in amplitude over time:
- **Attack**: How quickly the sound reaches full volume
- **Decay**: The initial fall in volume after the attack
- **Sustain**: The steady volume level maintained while a note is held
- **Release**: How quickly the sound fades after the note ends

Different instruments have characteristic envelopes:
- Piano: Fast attack, no sustain (immediate decay)
- Violin: Variable attack, can sustain indefinitely while bowing
- Percussion: Very fast attack, short decay, no sustain
Flat Tone (No Envelope):
Shaped Tone (With ADSR Envelope):
# Visualizing Waveforms of Different Instruments

import numpy as np
import matplotlib.pyplot as plt
from IPython.display import Audio, display
import librosa.display
from scipy.signal import hilbert
from scipy.signal import savgol_filter  # For smoothing envelopes

# Function to generate and analyze synthetic instrument waveforms
def analyze_synthetic_instrument(instrument_function, instrument_name, duration=2.0, sr=22050):
    # Generate synthetic waveform
    t = np.linspace(0, duration, int(sr * duration), endpoint=False)
    y = instrument_function(t, sr)
    
    # Plot the detailed views
    plt.figure(figsize=(15, 4))  # Increased width by 25%
    
    # Plot the waveform - show only first 0.1 seconds for better detail
    plt.subplot(1, 3, 1)
    # Only show first 0.1 seconds (or 10% of the signal if duration is very short)
    display_samples = min(int(0.1 * sr), len(y) // 10)
    librosa.display.waveshow(y[:display_samples], sr=sr)
    plt.title(f'{instrument_name} Waveform (First 0.1s)')
    plt.xlabel('Time (s)')
    plt.ylabel('Amplitude')
    
    # Plot the spectrogram
    plt.subplot(1, 3, 2)
    D = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max)
    librosa.display.specshow(D, sr=sr, x_axis='time', y_axis='log')
    plt.colorbar(format='%+2.0f dB')
    plt.title(f'{instrument_name} Spectrogram')
    
    # Plot the envelope with improved smoothing
    plt.subplot(1, 3, 3)
    envelope = np.abs(hilbert(y))  # Using scipy.signal.hilbert instead of librosa.hilbert
    
    # Apply stronger smoothing to the envelope
    window_length = min(501, len(envelope) // 5)  # Larger window for smoother curve
    if window_length % 2 == 0:  # Window length must be odd
        window_length += 1
    smooth_envelope = savgol_filter(envelope, window_length, 3)  # Savitzky-Golay filter
    
    plt.plot(t, smooth_envelope)
    plt.title(f'{instrument_name} Envelope (Smoothed)')
    plt.xlabel('Time (s)')
    plt.ylabel('Amplitude')
    
    plt.tight_layout()
    plt.show()
    
    # Play the audio
    print(f"Listen to the {instrument_name} sample:")
    display(Audio(y, rate=sr))
    print("\n")

# Synthetic instrument waveform generators
def piano_like(t, sr):
    # Piano-like sound: rich harmonics with fast decay
    fundamental = 440  # A4 note
    harmonics = [1, 2, 3, 4, 5, 6]
    strengths = [1.0, 0.5, 0.33, 0.25, 0.2, 0.16]
    
    # Generate harmonics
    y = np.zeros_like(t)
    for h, s in zip(harmonics, strengths):
        y += s * np.sin(2 * np.pi * fundamental * h * t)
    
    # Apply piano-like envelope (fast attack, long decay)
    envelope = np.exp(-5 * t)
    return y * envelope

def violin_like(t, sr):
    # Violin-like sound: odd harmonics with vibrato
    fundamental = 440  # A4 note
    harmonics = [1, 3, 5, 7]
    strengths = [1.0, 0.4, 0.2, 0.1]
    
    # Generate harmonics with vibrato
    vibrato_rate = 5  # Hz
    vibrato_depth = 5  # Hz
    
    y = np.zeros_like(t)
    for h, s in zip(harmonics, strengths):
        # Add vibrato to the frequency
        freq = fundamental * h + vibrato_depth * np.sin(2 * np.pi * vibrato_rate * t)
        phase = 2 * np.pi * np.cumsum(freq) / sr
        y += s * np.sin(phase)
    
    # Apply violin-like envelope (slow attack, sustained)
    attack = 0.1 * sr  # 0.1 seconds
    envelope = np.ones_like(t)
    envelope[:int(attack)] = np.linspace(0, 1, int(attack))
    envelope = envelope * np.exp(-0.5 * t)
    return y * envelope / np.max(np.abs(y))

def guitar_like(t, sr):
    # Guitar-like sound: rich harmonics with pluck
    fundamental = 196  # G3 note
    harmonics = [1, 2, 3, 4, 5, 6, 7, 8]
    strengths = [1.0, 0.5, 0.33, 0.25, 0.2, 0.16, 0.14, 0.12]
    
    # Generate harmonics
    y = np.zeros_like(t)
    for h, s in zip(harmonics, strengths):
        y += s * np.sin(2 * np.pi * fundamental * h * t)
    
    # Apply guitar-like envelope (fast attack, medium decay)
    envelope = np.exp(-2 * t)
    # Add a slight "pluck" at the beginning
    pluck_duration = int(0.01 * sr)  # 10ms
    if pluck_duration > 0:
        envelope[:pluck_duration] *= np.linspace(0, 1, pluck_duration)
    return y * envelope

def flute_like(t, sr):
    # Flute-like sound: fewer harmonics, airy quality
    fundamental = 523.25  # C5 note
    harmonics = [1, 2, 3]
    strengths = [1.0, 0.1, 0.05]
    
    # Generate harmonics
    y = np.zeros_like(t)
    for h, s in zip(harmonics, strengths):
        y += s * np.sin(2 * np.pi * fundamental * h * t)
    
    # Add some noise for the airy quality
    noise = np.random.normal(0, 0.05, len(t))
    y += noise
    
    # Apply flute-like envelope (slow attack, sustained)
    attack = 0.2 * sr  # 0.2 seconds
    envelope = np.ones_like(t)
    envelope[:int(attack)] = np.linspace(0, 1, int(attack))
    envelope = envelope * np.exp(-0.2 * t)
    return y * envelope / np.max(np.abs(y))

def drum_like(t, sr):
    # Drum-like sound: noise with fast decay
    # Generate noise
    y = np.random.normal(0, 1, len(t))
    
    # Apply drum-like envelope (very fast attack, fast decay)
    envelope = np.exp(-20 * t)
    
    # Add a low-frequency component for the "thump"
    thump = 0.8 * np.sin(2 * np.pi * 80 * t) * np.exp(-40 * t)
    
    return (y * envelope + thump) / np.max(np.abs(y * envelope + thump))

# Dictionary of synthetic instrument functions
instrument_functions = {
    "Piano": piano_like,
    "Violin": violin_like,
    "Guitar": guitar_like,
    "Flute": flute_like,
    "Drum Kit": drum_like
}

# Process each instrument
for instrument, func in instrument_functions.items():
    print(f"## {instrument} ##")
    analyze_synthetic_instrument(func, instrument)
## Piano ##
Listen to the Piano sample:

## Violin ##
Listen to the Violin sample:

## Guitar ##
Listen to the Guitar sample:

## Flute ##
Listen to the Flute sample:

## Drum Kit ##
Listen to the Drum Kit sample:

# Define sample rate and time array for the drum sound
sr = 44100  # Sample rate in Hz
duration = 1.0  # Duration in seconds
t = np.linspace(0, duration, int(sr * duration), endpoint=False)

# Drum-like sound: noise with fast decay
# Generate noise
y = np.random.normal(0, 1, len(t))

# Apply drum-like envelope (very fast attack, fast decay)
envelope = np.exp(-20 * t)

# Add a low-frequency component for the "thump"
thump = 0.8 * np.sin(2 * np.pi * 80 * t) * np.exp(-40 * t)

# Plot the individual components
plt.figure(figsize=(15, 9))

plt.subplot(3, 1, 1)
plt.plot(t[:1000], y[:1000])
plt.title('Noise Component')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.grid(True)

plt.subplot(3, 1, 2)
plt.plot(t[:1000], thump[:1000])
plt.title('Thump Component')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.grid(True)

plt.subplot(3, 1, 3)
noise_with_envelope = y * envelope
plt.plot(t[:1000], noise_with_envelope[:1000])
plt.title('Noise with Envelope Applied')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.grid(True)

plt.tight_layout()
plt.show()

# Now show the envelope separately
plt.figure(figsize=(15, 6))

plt.subplot(2, 1, 1)
plt.plot(t[:int(0.2*len(t))], envelope[:int(0.2*len(t))])
plt.title('Drum Envelope (First 20% of Duration)')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.ylim(0, 1.1)
plt.annotate('Rapid exponential decay', xy=(t[int(0.05*len(t))], envelope[int(0.05*len(t))]), 
             xytext=(t[int(0.1*len(t))], 0.8),
             arrowprops=dict(facecolor='red', shrink=0.05))
plt.grid(True)

plt.subplot(2, 1, 2)
final_sound = (y * envelope + thump) / np.max(np.abs(y * envelope + thump))
plt.plot(t[:int(0.2*len(t))], final_sound[:int(0.2*len(t))])
plt.title('Final Drum Sound (First 20% of Duration)')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.grid(True)

plt.tight_layout()
plt.show()

# Plot the full final drum sound
plt.figure(figsize=(10, 4))
final_sound = (y * envelope + thump) / np.max(np.abs(y * envelope + thump))
plt.plot(t, final_sound)
plt.title('Complete Drum Sound Waveform')
plt.xlabel('Time (s)')
plt.ylabel('Amplitude')
plt.grid(True)
plt.show()

# Create the audio player widget that can be clicked to play the sound
from IPython.display import Audio, display
drum_audio = Audio(data=final_sound, rate=sr)
display(drum_audio)
import ipywidgets as widgets
from IPython.display import display, Audio
import numpy as np
import matplotlib.pyplot as plt

# Music theory section - showing scales and chords
def generate_note_frequency(note, octave=4):
    """Generate frequency for a given note and octave"""
    notes = {'C': 0, 'C#': 1, 'D': 2, 'D#': 3, 'E': 4, 'F': 5, 
             'F#': 6, 'G': 7, 'G#': 8, 'A': 9, 'A#': 10, 'B': 11}
    
    # A4 = 440Hz (MIDI note 69)
    A4_FREQ = 440
    A4_MIDI = 69
    
    # Calculate MIDI note number
    midi_note = 12 * (octave + 1) + notes[note]
    
    # Calculate frequency
    frequency = A4_FREQ * 2**((midi_note - A4_MIDI) / 12)
    return frequency

def create_chord(root_note, chord_type='major', octave=4, duration=1.0, sr=44100):
    """Create a chord from a root note"""
    t = np.linspace(0, duration, int(sr * duration), endpoint=False)
    
    # Define intervals for common chord types
    intervals = {
        'major': [0, 4, 7],       # Root, Major 3rd, Perfect 5th
        'minor': [0, 3, 7],       # Root, Minor 3rd, Perfect 5th
        'diminished': [0, 3, 6],  # Root, Minor 3rd, Diminished 5th
        'augmented': [0, 4, 8],   # Root, Major 3rd, Augmented 5th
        '7th': [0, 4, 7, 10],     # Root, Major 3rd, Perfect 5th, Minor 7th
        'maj7': [0, 4, 7, 11],    # Root, Major 3rd, Perfect 5th, Major 7th
    }
    
    # Generate frequencies for the chord
    root_freq = generate_note_frequency(root_note, octave)
    chord_freqs = [root_freq * 2**(interval/12) for interval in intervals[chord_type]]
    
    # Create the chord sound (sum of sine waves)
    chord = np.zeros_like(t)
    individual_waves = []
    
    for freq in chord_freqs:
        wave = 0.5 * np.sin(2 * np.pi * freq * t)
        individual_waves.append(wave)
        chord += wave
    
    # Normalize
    chord = chord / np.max(np.abs(chord))
    
    # Apply a simple envelope
    envelope = np.exp(-3 * t)
    chord = chord * envelope
    
    # Apply envelope to individual waves too for visualization
    individual_waves = [wave * envelope for wave in individual_waves]
    
    return chord, individual_waves, t, chord_freqs

# Interactive chord player
@widgets.interact(
    root_note=['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'],
    chord_type=['major', 'minor', 'diminished', 'augmented', '7th', 'maj7'],
    octave=(2, 6, 1)
)
def play_chord(root_note='C', chord_type='major', octave=4):
    chord, individual_waves, t, frequencies = create_chord(root_note, chord_type, octave)
    
    # Display the chord's notes
    intervals = {
        'major': [0, 4, 7],
        'minor': [0, 3, 7],
        'diminished': [0, 3, 6],
        'augmented': [0, 4, 8],
        '7th': [0, 4, 7, 10],
        'maj7': [0, 4, 7, 11],
    }
    
    notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']
    root_idx = notes.index(root_note)
    
    chord_notes = [notes[(root_idx + interval) % 12] for interval in intervals[chord_type]]
    print(f"{root_note} {chord_type}: {', '.join(chord_notes)}")
    
    # Plot the waveforms
    plt.figure(figsize=(15, 8))
    
    # Plot individual note waveforms
    num_notes = len(individual_waves)
    for i, (wave, note, freq) in enumerate(zip(individual_waves, chord_notes, frequencies)):
        plt.subplot(num_notes + 1, 1, i + 1)
        # Only show first 0.1 seconds for better visualization
        display_samples = int(0.1 * len(t))
        plt.plot(t[:display_samples], wave[:display_samples])
        plt.title(f'Note: {note} ({freq:.1f} Hz)')
        plt.ylabel('Amplitude')
        plt.grid(True)
    
    # Plot combined chord waveform
    plt.subplot(num_notes + 1, 1, num_notes + 1)
    plt.plot(t[:display_samples], chord[:display_samples])
    plt.title('Combined Chord Waveform')
    plt.xlabel('Time (s)')
    plt.ylabel('Amplitude')
    plt.grid(True)
    
    plt.tight_layout()
    plt.show()
    
    return Audio(chord, rate=44100)
@widgets.interact(
    fundamental=(110, 880, 10),
    num_harmonics=(1, 8, 1),
    attack=(0.01, 0.5, 0.01),
    decay=(0.01, 1.0, 0.01),
    sustain_level=(0.0, 1.0, 0.1),
    release=(0.01, 2.0, 0.01)
)
def custom_instrument(fundamental=440, num_harmonics=3, attack=0.1, decay=0.2, 
                      sustain_level=0.7, release=0.3):
    """Create a custom instrument with user-specified parameters"""
    sr = 44100
    duration = 2.0
    t = np.linspace(0, duration, int(sr * duration), endpoint=False)
    
    # Generate harmonics
    y = np.zeros_like(t)
    harmonics = []
    
    plt.figure(figsize=(15, 9))
    
    # Calculate harmonic strengths using exponential falloff
    strengths = []
    for i in range(1, num_harmonics + 1):
        # Using exponential falloff
        strength = 1.0 / (2 ** (i - 1))
        
        strengths.append(strength)
        harmonic = strength * np.sin(2 * np.pi * fundamental * i * t)
        harmonics.append(harmonic)
        y += harmonic
    
    # Normalize
    y = y / np.max(np.abs(y))
    
    # Create ADSR envelope
    envelope = np.ones_like(t)
    attack_samples = int(attack * sr)
    decay_samples = int(decay * sr)
    release_samples = int(release * sr)
    release_start = int(sr * (duration - release))
    
    # Attack phase
    if attack_samples > 0:
        envelope[:attack_samples] = np.linspace(0, 1, attack_samples)
    
    # Decay phase
    if decay_samples > 0:
        envelope[attack_samples:attack_samples+decay_samples] = np.linspace(1, sustain_level, decay_samples)
    
    # Sustain phase (already set to sustain_level by decay)
    envelope[attack_samples+decay_samples:release_start] = sustain_level
    
    # Release phase
    if release_samples > 0:
        envelope[release_start:] = np.linspace(sustain_level, 0, len(t) - release_start)
    
    # Apply envelope
    y = y * envelope
    
    # Plot harmonics
    plt.subplot(5, 1, 1)
    display_samples = 1000  # Show first 1000 samples for better visualization
    for i, harmonic in enumerate(harmonics):
        plt.plot(t[:display_samples], harmonic[:display_samples], 
                 label=f'Harmonic {i+1} ({fundamental*(i+1):.1f} Hz)')
    plt.title('Individual Harmonics')
    plt.grid(True)
    plt.legend()
    
    # Plot harmonic spectrum right after harmonics
    plt.subplot(5, 1, 2)
    frequencies = [fundamental * i for i in range(1, num_harmonics + 1)]
    plt.bar(frequencies, strengths, width=fundamental*0.3)
    plt.title('Harmonic Spectrum')
    plt.xlabel('Frequency (Hz)')
    plt.ylabel('Amplitude')
    plt.grid(True)
    
    # Plot combined waveform before envelope
    plt.subplot(5, 1, 3)
    combined = np.sum(harmonics, axis=0)
    combined = combined / np.max(np.abs(combined))  # Normalize
    plt.plot(t[:display_samples], combined[:display_samples])
    plt.title('Combined Waveform (Before Envelope)')
    plt.grid(True)
    
    # Plot envelope
    plt.subplot(5, 1, 4)
    plt.plot(t, envelope)
    plt.title('ADSR Envelope')
    plt.grid(True)
    
    # Plot final waveform with envelope
    plt.subplot(5, 1, 5)
    plt.plot(t[:display_samples], y[:display_samples])
    plt.title('Final Waveform (With Envelope)')
    plt.grid(True)
    
    plt.tight_layout()
    plt.show()
    
    return Audio(y, rate=sr)
import numpy as np 
import matplotlib.pyplot as plt
import librosa
from scipy.signal import savgol_filter, hilbert
from IPython.display import Audio, display

def analyze_synthetic_instrument(instrument_function, instrument_name, duration=3.0, sr=22050):
    # Generate synthetic waveform
    t = np.linspace(0, duration, int(sr * duration), endpoint=False)
    y = instrument_function(t, sr)
    
    # Plot the waveform
    plt.figure(figsize=(12, 4))
    plt.subplot(1, 3, 1)
    librosa.display.waveshow(y, sr=sr)
    plt.title(f'{instrument_name} Waveform')
    plt.xlabel('Time (s)')
    plt.ylabel('Amplitude')
    
    # Plot the spectrogram
    plt.subplot(1, 3, 2)
    D = librosa.amplitude_to_db(np.abs(librosa.stft(y)), ref=np.max)
    librosa.display.specshow(D, sr=sr, x_axis='time', y_axis='log')
    plt.colorbar(format='%+2.0f dB')
    plt.title(f'{instrument_name} Spectrogram')
    
    # Plot the envelope with smoothing
    plt.subplot(1, 3, 3)
    envelope = np.abs(hilbert(y))
    # Apply smoothing to the envelope
    window_length = min(101, len(envelope) // 10)
    if window_length % 2 == 0:  # Window length must be odd
        window_length += 1
    smooth_envelope = savgol_filter(envelope, window_length, 3)
    plt.plot(t, smooth_envelope)
    plt.title(f'{instrument_name} Envelope (Smoothed)')
    plt.xlabel('Time (s)')
    plt.ylabel('Amplitude')
    
    plt.tight_layout()
    plt.show()
    
    # Display audio player
    # display(Audio(y, rate=sr))

def lumina_like(t, sr):
    # Beautiful Lumina with natural decay characteristics
    fundamental = 293.66  # D4 note
    duration = len(t) / sr  # Total duration in seconds
    
    # Use consonant harmonic series
    harmonic_series = [1, 2, 3, 5, 8, 13]
    strengths = [1.0, 0.65, 0.4, 0.25, 0.12, 0.07]
    
    # Create the base sound
    y = np.zeros_like(t)
    
    # Light vibrato
    vibrato_rate = 4.5  # Hz
    vibrato_depth = 1.8  # Hz - subtle
    vibrato = vibrato_depth * np.sin(2 * np.pi * vibrato_rate * t)
    
    # Generate each harmonic with its own decay rate
    for i, (h, s) in enumerate(zip(harmonic_series, strengths)):
        # Higher harmonics decay faster (physically accurate)
        harmonic_decay_rate = 1.2 + (h * 0.5)  # Scaling decay with harmonic number
        
        # Two-phase decay (like many natural instruments)
        initial_decay = np.exp(-harmonic_decay_rate * t)
        
        # Blend initial and residual decay
        harmonic_envelope = initial_decay
        
        # Frequency with subtle vibrato
        freq = fundamental * h + vibrato * (h/4)
        phase = 2 * np.pi * np.cumsum(freq) / sr
        
        # Add to the waveform
        y += s * harmonic_envelope * np.sin(phase)
    
    # Add subtle chorus for richness (single layer to keep it simple)
    chorus_depth = 0.01
    chorus_rate = 0.7  # Hz
    chorus_mod = chorus_depth * np.sin(2 * np.pi * chorus_rate * t)
    chorus_signal = np.zeros_like(t)
    
    for h, s in zip(harmonic_series[:4], strengths[:4]):  # Only use lower harmonics for chorus
        freq = fundamental * h * (1 + chorus_mod)
        phase = 2 * np.pi * np.cumsum(freq) / sr
        # Apply faster decay to chorus layer
        chorus_decay = np.exp(-(2.0 + h*0.5) * t)
        chorus_signal += (s * 0.2) * chorus_decay * np.sin(phase)
    
    y += chorus_signal
    
    # Master envelope with natural attack and decay
    envelope = np.ones_like(t)
    attack_time = 0.05  # Much faster attack - 50ms
    attack_samples = int(attack_time * sr)
    
    if attack_samples > 0 and attack_samples < len(t):
        envelope[:attack_samples] = np.power(np.linspace(0, 1, attack_samples), 0.7)
    
    # Apply natural sounding decay
    # Fast initial decay followed by longer tail (common in struck/plucked instruments)
    decay_1 = np.exp(-3.0 * t)  # Initial rapid decay
    decay_2 = np.exp(-1.5 * t)  # Slower residual decay
    
    # Blend the two decay curves - more of decay_1 at the start, more of decay_2 for the tail
    blend_factor = np.exp(-5.0 * t)  # How quickly to transition from decay_1 to decay_2
    master_decay = decay_1 * blend_factor + decay_2 * (1 - blend_factor)
    
    # Final envelope
    final_envelope = envelope * master_decay
    
    # Subtle resonance to add warmth to the decay
    resonance_freq = fundamental * 2.5  # A harmonically related resonance
    resonance = 0.06 * np.sin(2 * np.pi * resonance_freq * t) * np.exp(-2.0 * t)
    
    # Combine all elements
    result = (y * final_envelope + resonance) / 1.2  # Prevent clipping
    return result / np.max(np.abs(result))

# Create the instrument dictionary if it doesn't exist
instrument_functions = {}

# Add the beautiful Lumina to the instrument dictionary
instrument_functions["Lumina"] = lumina_like

# Define sample rate if not already defined
sample_rate = 22050

# Analyze the new instrument
print("## Analyzing the new instrument, called 'Lumina' ##")
analyze_synthetic_instrument(lumina_like, "Lumina", duration=2.0)  # 3 seconds is enough to hear the decay

# Generate the Lumina sound for playback
t_lumina = np.linspace(0, 2.0, int(sample_rate * 2.0), endpoint=False)
lumina_sound = lumina_like(t_lumina, sample_rate)

# Play the Lumina sound
display(Audio(lumina_sound, rate=sample_rate))
## Analyzing the new instrument, called 'Lumina' ##
def lumina_like(t, sr, fundamental=293.66):  # Modified to accept fundamental frequency
    # Beautiful Lumina with natural decay characteristics
    duration = len(t) / sr  # Total duration in seconds
    
    # Use consonant harmonic series
    harmonic_series = [1, 2, 3, 5, 8, 13]
    strengths = [1.0, 0.65, 0.4, 0.25, 0.12, 0.07]
    
    # Create the base sound
    y = np.zeros_like(t)
    
    # Light vibrato
    vibrato_rate = 4.5  # Hz
    vibrato_depth = 1.8  # Hz - subtle
    vibrato = vibrato_depth * np.sin(2 * np.pi * vibrato_rate * t)
    
    # Generate each harmonic with its own decay rate
    for i, (h, s) in enumerate(zip(harmonic_series, strengths)):
        # Higher harmonics decay faster (physically accurate)
        harmonic_decay_rate = 1.2 + (h * 0.5)  # Scaling decay with harmonic number
        
        # Two-phase decay (like many natural instruments)
        initial_decay = np.exp(-harmonic_decay_rate * t)
        
        # Blend initial and residual decay
        harmonic_envelope = initial_decay
        
        # Frequency with subtle vibrato
        freq = fundamental * h + vibrato * (h/4)
        phase = 2 * np.pi * np.cumsum(freq) / sr
        
        # Add to the waveform
        y += s * harmonic_envelope * np.sin(phase)
    
    # Add subtle chorus for richness (single layer to keep it simple)
    chorus_depth = 0.01
    chorus_rate = 0.7  # Hz
    chorus_mod = chorus_depth * np.sin(2 * np.pi * chorus_rate * t)
    chorus_signal = np.zeros_like(t)
    
    for h, s in zip(harmonic_series[:4], strengths[:4]):  # Only use lower harmonics for chorus
        freq = fundamental * h * (1 + chorus_mod)
        phase = 2 * np.pi * np.cumsum(freq) / sr
        # Apply faster decay to chorus layer
        chorus_decay = np.exp(-(2.0 + h*0.5) * t)
        chorus_signal += (s * 0.2) * chorus_decay * np.sin(phase)
    
    y += chorus_signal
    
    # Master envelope with natural attack and decay
    envelope = np.ones_like(t)
    attack_time = 0.05  # Much faster attack - 50ms
    attack_samples = int(attack_time * sr)
    
    if attack_samples > 0 and attack_samples < len(t):
        envelope[:attack_samples] = np.power(np.linspace(0, 1, attack_samples), 0.7)
    
    # Apply natural sounding decay
    # Fast initial decay followed by longer tail (common in struck/plucked instruments)
    decay_1 = np.exp(-3.0 * t)  # Initial rapid decay
    decay_2 = np.exp(-1.5 * t)  # Slower residual decay
    
    # Blend the two decay curves - more of decay_1 at the start, more of decay_2 for the tail
    blend_factor = np.exp(-5.0 * t)  # How quickly to transition from decay_1 to decay_2
    master_decay = decay_1 * blend_factor + decay_2 * (1 - blend_factor)
    
    # Final envelope
    final_envelope = envelope * master_decay
    
    # Subtle resonance to add warmth to the decay
    resonance_freq = fundamental * 2.5  # A harmonically related resonance
    resonance = 0.06 * np.sin(2 * np.pi * resonance_freq * t) * np.exp(-2.0 * t)
    
    # Combine all elements
    result = (y * final_envelope + resonance) / 1.2  # Prevent clipping
    return result / np.max(np.abs(result))

# Add the beautiful Lumina to the instrument dictionary
instrument_functions["Lumina"] = lumina_like

# Analyze the new instrument
print("## Lumina with Natural Decay ##")
# analyze_synthetic_instrument(lumina_like, "Lumina", duration=2.0)  # 3 seconds is enough to hear the decay

# Generate the Lumina sound for playback
t_lumina = np.linspace(0, 2.0, int(sample_rate * 2.0), endpoint=False)
lumina_sound = lumina_like(t_lumina, sample_rate)

# Play the Lumina sound
# display(Audio(lumina_sound, rate=sample_rate))

# Create a keyboard interface to play the Lumina instrument
from IPython.display import display, HTML
import ipywidgets as widgets
from IPython.display import clear_output

# Define note frequencies for a C major scale
note_frequencies = {
    'a': 261.63,  # C4
    's': 293.66,  # D4
    'd': 329.63,  # E4
    'f': 349.23,  # F4
    'g': 392.00,  # G4
    'h': 440.00,  # A4
    'j': 493.88,  # B4
    'k': 523.25,  # C5
    'l': 587.33   # D5
}

# Create a short duration for each note
note_duration = 1.0  # seconds
t_note = np.linspace(0, note_duration, int(sample_rate * note_duration), endpoint=False)

# Pre-generate audio for each note
note_sounds = {}
for key, freq in note_frequencies.items():
    note_sounds[key] = lumina_like(t_note, sample_rate, fundamental=freq)

# Create a text widget to capture keyboard input
text_widget = widgets.Text(
    value='',
    placeholder='Click here and press a, s, d, f, g, h, j, k, l to play notes',
    description='Play:',
    layout=widgets.Layout(width='500px')
)

# Create an output widget to display the played note
output_widget = widgets.Output()

# Function to handle keyboard input
def handle_key(change):
    key = change['new']
    if key and key[-1] in note_frequencies:
        pressed_key = key[-1]
        # Clear previous output to stop any currently playing audio
        output_widget.clear_output(wait=True)
        # Play the note for the specific key pressed
        with output_widget:
            # Generate a unique ID for this audio element
            audio_id = f"audio_{pressed_key}_{np.random.randint(10000)}"
            # Store the key and frequency for debugging
            note_name = f"{pressed_key.upper()} ({note_frequencies[pressed_key]} Hz)"
            print(f"Playing: {note_name}")
            # Display the audio with the correct frequency for this key
            display(Audio(note_sounds[pressed_key], rate=sample_rate, autoplay=True, element_id=audio_id))
            # Force interrupt any currently playing audio and play the new note
            display(HTML(f"""
            <script>
                // Stop all currently playing audio elements
                document.querySelectorAll('audio').forEach(function(audio) {{
                    audio.pause();
                    audio.currentTime = 0;
                }});
                
                // Make sure the latest audio plays
                var latestAudio = document.getElementById('{audio_id}');
                if (latestAudio) {{
                    latestAudio.play();
                }}
            </script>
            """))
        # Clear the input for the next key press
        text_widget.value = ''

# Connect the handler to the text widget
text_widget.observe(handle_key, names='value')

# Display the interface
print("Keyboard Piano - Press keys a, s, d, f, g, h, j, k, l to play notes")
display(text_widget)
display(output_widget)

# Display a visual keyboard guide
keyboard_guide = """
<div style="font-family: monospace; font-size: 16px; margin: 20px;">
  <div style="display: flex; justify-content: center;">
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">A<br>C4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">S<br>D4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">D<br>E4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">F<br>F4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">G<br>G4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">H<br>A4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">J<br>B4</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">K<br>C5</div>
    <div style="border: 1px solid black; padding: 10px; margin: 5px; width: 40px; text-align: center; background-color: #f0f0f0;">L<br>D5</div>
  </div>
</div>
"""
display(HTML(keyboard_guide))
## Lumina with Natural Decay ##
Keyboard Piano - Press keys a, s, d, f, g, h, j, k, l to play notes
A
C4
S
D4
D
E4
F
F4
G
G4
H
A4
J
B4
K
C5
L
D5