!pip install numpy matplotlib ipython librosa ipywidgets
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.")
# 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))
# 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)
# 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))
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))