diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c96683e --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +output/ +.DS_Store \ No newline at end of file diff --git a/Example Song.wav b/Example Song.wav new file mode 100644 index 0000000..e7a0427 Binary files /dev/null and b/Example Song.wav differ diff --git a/README.md b/README.md index c0bf3a7..755f171 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,46 @@ -# muse +# Muse - A Go-based Music Generator + +Muse is my attempt at a digital music generator written in pure Go. Its is VERY experimental and is not intended for production use. Enjoy! + +## Features + +- Define songs using a YAML file +- Multiple wave types (sine, square, saw, triangle) +- Built-in effects (reverb, delay, filter) +- Pattern-based sequencing +- Real-time audio generation +- Export to WAV format + +## Usage + +```bash +./muse -input examples/example_song.yaml -output ./output +``` + +## Project Structure + +``` +muse/ +├── bin/ +│ └── muse # Compiled binary +├── cmd/ +│ └── muse/ # Main application +│ └── main.go +├── lib/ +│ ├── audio/ # Audio generation and processing +│ │ ├── effects.go # Audio effects +│ │ ├── generator.go # Audio generation +│ │ ├── notes.go # Note to frequency conversion +│ │ └── oscillator.go # Waveform generation +│ └── config/ # Configuration and YAML parsing +│ └── parser.go +├── examples/ # Example songs +│ └── example.yaml +└── schema/ # Data schema + └── song_schema.go +``` + +## License + +This project is licensed under the AGPL 3.0 License - see the [LICENSE](LICENSE) file for details. diff --git a/bin/muse b/bin/muse new file mode 100755 index 0000000..3f50f6b Binary files /dev/null and b/bin/muse differ diff --git a/cmd/muse/main.go b/cmd/muse/main.go new file mode 100644 index 0000000..1798f92 --- /dev/null +++ b/cmd/muse/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "flag" + "log" + "os" + "path/filepath" + "strings" + "time" + "unicode" + + "git.atri.dad/atridad/muse/lib/audio" + "git.atri.dad/atridad/muse/lib/config" +) + +func main() { + // Define command line flags + inputFile := flag.String("input", "", "Path to the YAML file containing the song definition") + outputDir := flag.String("output", ".", "Directory to save the generated audio file") + help := flag.Bool("help", false, "Show help message") + + flag.Parse() + + // Show help if requested or no input file provided + if *help || *inputFile == "" { + flag.Usage() + os.Exit(0) + } + + // Validate input file exists + if _, err := os.Stat(*inputFile); os.IsNotExist(err) { + log.Fatalf("Input file does not exist: %s", *inputFile) + } + + // Create output directory if it doesn't exist + if err := os.MkdirAll(*outputDir, 0755); err != nil { + log.Fatalf("Failed to create output directory: %v", err) + } + + // Load and parse YAML file + log.Printf("Loading song from: %s\n", *inputFile) + song, err := config.LoadSong(*inputFile) + if err != nil { + log.Fatalf("Failed to load song: %v", err) + } + log.Printf("Successfully loaded song: %s (BPM: %d, Length: %d bars)", song.Name, song.BPM, song.Length) + + // Generate audio + generator := audio.NewGenerator() + startTime := time.Now() + log.Println("Starting audio generation...") + + audioData, err := generator.Generate(*song) + if err != nil { + log.Fatalf("Failed to generate audio: %v", err) + } + log.Printf("Generated %d samples (%.2f seconds)\n", + len(audioData), float64(len(audioData))/float64(audio.SampleRate*audio.Channels)) + + // Normalize audio to prevent clipping + audio.Normalize(audioData) + + // Create output filename based on song name + safeName := strings.Map(func(r rune) rune { + if unicode.IsLetter(r) || unicode.IsNumber(r) || r == ' ' || r == '-' || r == '_' { + return r + } + return '-' + }, song.Name) + + // Ensure output directory exists + if err := os.MkdirAll(*outputDir, 0755); err != nil { + log.Fatalf("Failed to create output directory: %v", err) + } + + outputPath := filepath.Join(*outputDir, safeName+".wav") + + // Write WAV file + log.Printf("Writing WAV file to: %s\n", outputPath) + if err := generator.WriteWAV(outputPath, audioData); err != nil { + log.Fatalf("Failed to write WAV file: %v", err) + } + + // Verify file was created + if _, err := os.Stat(outputPath); os.IsNotExist(err) { + log.Fatalf("Failed to verify output file was created: %v", err) + } + + fileInfo, _ := os.Stat(outputPath) + log.Printf("Success! Generated '%s' in %0.3fs\nFile size: %d bytes\nOutput: %s\n", + song.Name, + time.Since(startTime).Seconds(), + fileInfo.Size(), + outputPath, + ) +} diff --git a/examples/example.yaml b/examples/example.yaml new file mode 100644 index 0000000..b7efb0d --- /dev/null +++ b/examples/example.yaml @@ -0,0 +1,41 @@ +name: "Example Song" +bpm: 120 # Beats per minute +length: 16 # Number times to loop +volume: 0.8 + +tracks: + # Loop throughout the song + - name: "Drums" + instrument: "sine" + volume: 0.9 + pan: 0.0 + pattern: ["C1", ".", "D2", "."] # Note pattern, where "." is a rest and a note is a beat + loop: true + + # 64 Beats with reverb effect + - name: "Melody" + instrument: "sine" + volume: 0.7 + pan: 0.2 + pattern: ["C4", "D4", "E4", "F4", "G4", "A4", "B4", "C5", + "B4", "A4", "G4", "F4", "E4", "D4", "C4", ".", + "F4", "G4", "A4", "Bb4", "C5", "D5", "C5", "Bb4", + "A4", "G4", "F4", "E4", "D4", "C4", ".", ".", + "G4", "A4", "B4", "C5", "D5", "E5", "D5", "C5", + "B4", "A4", "G4", "F4", "E4", "D4", "C4", ".", + "A4", "B4", "C5", "D5", "E5", "F5", "E5", "D5", + "C5", "B4", "A4", "G4", "F4", "E4", "D4", "C4"] + loop: false + effects: + - type: "reverb" + params: + decay: 2.0 + wet: 0.4 + + # Loops throughout the song + - name: "Bass" + instrument: "saw" + volume: 0.8 + pan: 0.0 + pattern: [".", ".", ".", ".", "A1", ".", "F1", "."] + loop: true diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..bbcca5a --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module git.atri.dad/atridad/muse + +go 1.24.3 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62c313 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lib/audio/effects.go b/lib/audio/effects.go new file mode 100644 index 0000000..040cf17 --- /dev/null +++ b/lib/audio/effects.go @@ -0,0 +1,133 @@ +package audio + +import ( + "git.atri.dad/atridad/muse/schema" +) + +// Defines the interface for audio effects +type EffectProcessor interface { + Process(samples []float64) []float64 +} + +// Holds a sequence of effects to be applied +type EffectChain struct { + effects []EffectProcessor +} + +// Adds an effect to the chain +func (ec *EffectChain) AddEffect(effect EffectProcessor) { + ec.effects = append(ec.effects, effect) +} + +// Applies all effects in the chain sequentially +func (ec *EffectChain) Process(samples []float64) []float64 { + for _, effect := range ec.effects { + samples = effect.Process(samples) + } + return samples +} + +// Implements a simple reverb effect +type ReverbEffect struct { + decay float64 + wet float64 + delayBuffer []float64 + delayIndex int + sampleRate float64 +} + +// Creates a new reverb effect with specified parameters +func NewReverbEffect(decay, wet, sampleRate float64) *ReverbEffect { + delaySize := int(0.1 * sampleRate) + return &ReverbEffect{ + decay: decay, + wet: wet, + delayBuffer: make([]float64, delaySize), + delayIndex: 0, + sampleRate: sampleRate, + } +} + +// Applies reverb effect to audio samples +func (r *ReverbEffect) Process(samples []float64) []float64 { + output := make([]float64, len(samples)) + + for i, sample := range samples { + delayed := r.delayBuffer[r.delayIndex] + r.delayBuffer[r.delayIndex] = sample + delayed*r.decay + output[i] = sample*(1.0-r.wet) + delayed*r.wet + r.delayIndex = (r.delayIndex + 1) % len(r.delayBuffer) + } + + return output +} + +// Implements a simple delay effect +type DelayEffect struct { + delayTime float64 + feedback float64 + wet float64 + delayBuffer []float64 + delayIndex int +} + +// Creates a new delay effect with specified parameters +func NewDelayEffect(delayTime, feedback, wet, sampleRate float64) *DelayEffect { + delaySize := int(delayTime * sampleRate) + return &DelayEffect{ + delayTime: delayTime, + feedback: feedback, + wet: wet, + delayBuffer: make([]float64, delaySize), + delayIndex: 0, + } +} + +// Applies delay effect to audio samples +func (d *DelayEffect) Process(samples []float64) []float64 { + output := make([]float64, len(samples)) + + for i, sample := range samples { + delayed := d.delayBuffer[d.delayIndex] + d.delayBuffer[d.delayIndex] = sample + delayed*d.feedback + output[i] = sample*(1.0-d.wet) + delayed*d.wet + d.delayIndex = (d.delayIndex + 1) % len(d.delayBuffer) + } + + return output +} + +// Creates an effect chain from schema effect definitions +func CreateEffectChain(effects []schema.Effect, sampleRate float64) *EffectChain { + chain := &EffectChain{} + + for _, effect := range effects { + switch effect.Type { + case "reverb": + decay := getFloatParam(effect.Params, "decay", 0.5) + wet := getFloatParam(effect.Params, "wet", 0.3) + chain.AddEffect(NewReverbEffect(decay, wet, sampleRate)) + + case "delay": + delayTime := getFloatParam(effect.Params, "time", 0.25) + feedback := getFloatParam(effect.Params, "feedback", 0.4) + wet := getFloatParam(effect.Params, "wet", 0.3) + chain.AddEffect(NewDelayEffect(delayTime, feedback, wet, sampleRate)) + } + } + + return chain +} + +// Safely extracts a float parameter with default fallback +func getFloatParam(params map[string]interface{}, key string, defaultValue float64) float64 { + if val, exists := params[key]; exists { + switch v := val.(type) { + case float64: + return v + case int: + return float64(v) + } + } + return defaultValue +} diff --git a/lib/audio/generator.go b/lib/audio/generator.go new file mode 100644 index 0000000..82bb80c --- /dev/null +++ b/lib/audio/generator.go @@ -0,0 +1,129 @@ +package audio + +import ( + "log" + "math" + "sync" + + "git.atri.dad/atridad/muse/schema" +) + +const ( + SampleRate = 44100 + Bits = 16 + Channels = 2 +) + +// Handles audio synthesis +type Generator struct { + sampleRate float64 + bufferPool sync.Pool +} + +// Creates a new audio generator +func NewGenerator() *Generator { + return &Generator{ + sampleRate: float64(SampleRate), + bufferPool: sync.Pool{ + New: func() interface{} { + return make([]float64, 0, 4096) + }, + }, + } +} + +// Renders the complete song to audio samples +func (g *Generator) Generate(song schema.Song) ([]float64, error) { + samplesPerBeat := (60.0 / float64(song.BPM)) * g.sampleRate + log.Printf("Generating song at %.1f BPM, sample rate: %.0f, samples per beat: %.1f\n", + float64(song.BPM), g.sampleRate, samplesPerBeat) + + totalBeats := song.CalculateTotalLength() + log.Printf("Total song length: %.1f beats", totalBeats) + + totalSamples := int(float64(totalBeats) * samplesPerBeat) + buffer := make([]float64, totalSamples*Channels) + + if totalSamples == 0 { + log.Printf("Warning: Zero samples calculated for song") + } + + var wg sync.WaitGroup + var mu sync.Mutex + + for _, track := range song.Tracks { + wg.Add(1) + go func(track schema.Track) { + defer wg.Done() + + patternLength := len(track.Pattern) + if patternLength == 0 { + return + } + + for beat := 0; beat < int(totalBeats); beat++ { + var note string + + if track.Loop { + patternIndex := beat % patternLength + note = track.Pattern[patternIndex] + } else { + if beat < patternLength { + note = track.Pattern[beat] + } else { + note = "." + } + } + + if note == "." || note == "" { + continue + } + + startSample := int(float64(beat) * samplesPerBeat) + endSample := startSample + int(samplesPerBeat) + + if startSample >= len(buffer)/Channels { + continue + } + if endSample > len(buffer)/Channels { + endSample = len(buffer) / Channels + } + + freq := noteToFreq(note) + amplitude := 0.5 + duration := float64(endSample-startSample) / g.sampleRate + + audioData := g.generateOscillator(track.Instrument, freq, duration, amplitude*track.Volume, 0) + if len(audioData) == 0 { + continue + } + + if len(track.Effects) > 0 { + effectChain := CreateEffectChain(track.Effects, g.sampleRate) + audioData = effectChain.Process(audioData) + } + + pan := (track.Pan + 1) / 2 + leftGain := math.Sqrt(1.0 - pan) + rightGain := math.Sqrt(pan) + + mu.Lock() + for i, sample := range audioData { + if startSample+i >= len(buffer)/Channels { + break + } + + leftSample := sample * leftGain + rightSample := sample * rightGain + + buffer[(startSample+i)*Channels] += leftSample + buffer[(startSample+i)*Channels+1] += rightSample + } + mu.Unlock() + } + }(track) + } + + wg.Wait() + return buffer, nil +} diff --git a/lib/audio/notes.go b/lib/audio/notes.go new file mode 100644 index 0000000..bebbe81 --- /dev/null +++ b/lib/audio/notes.go @@ -0,0 +1,44 @@ +package audio + +import "math" + +// Converts note name to frequency in Hz +func noteToFreq(note string) float64 { + noteFreqs := map[string]float64{ + "C": 16.35, + "C#": 17.32, + "D": 18.35, + "D#": 19.45, + "E": 20.60, + "F": 21.83, + "F#": 23.12, + "G": 24.50, + "G#": 25.96, + "A": 27.50, + "A#": 29.14, + "B": 30.87, + } + + if len(note) < 2 { + return 440.0 + } + + noteName := note[:1] + if len(note) > 2 && (note[1] == '#' || note[1] == 'b') { + noteName = note[:2] + } + + octave := 4 + if len(note) > 1 { + if note[len(note)-1] >= '0' && note[len(note)-1] <= '9' { + octave = int(note[len(note)-1] - '0') + } + } + + baseFreq, exists := noteFreqs[noteName] + if !exists { + return 440.0 + } + + return baseFreq * math.Pow(2, float64(octave)) +} diff --git a/lib/audio/oscillator.go b/lib/audio/oscillator.go new file mode 100644 index 0000000..ff95eb9 --- /dev/null +++ b/lib/audio/oscillator.go @@ -0,0 +1,80 @@ +package audio + +import "math" + +// Generates audio samples for a given waveform type +func (g *Generator) generateOscillator(waveType string, freq, duration, amplitude, phase float64) []float64 { + samples := int(duration * g.sampleRate) + if samples <= 0 { + return nil + } + output := make([]float64, samples) + + sampleRateInv := 1.0 / g.sampleRate + twoPiFreq := 2 * math.Pi * freq + fadeSamples := int(math.Min(0.005*g.sampleRate, float64(samples)*0.1)) + fadeSamplesInv := 1.0 / float64(fadeSamples) + switch waveType { + case "sine": + for i := 0; i < samples; i++ { + t := float64(i)*sampleRateInv + phase + sample := math.Sin(twoPiFreq*t) * amplitude + + if i < fadeSamples { + sample *= float64(i) * fadeSamplesInv + } else if i >= samples-fadeSamples { + sample *= float64(samples-i) * fadeSamplesInv + } + + output[i] = sample + } + case "square": + for i := 0; i < samples; i++ { + t := float64(i)*sampleRateInv + phase + var sample float64 + if math.Sin(twoPiFreq*t) > 0 { + sample = amplitude + } else { + sample = -amplitude + } + + if i < fadeSamples { + sample *= float64(i) * fadeSamplesInv + } else if i >= samples-fadeSamples { + sample *= float64(samples-i) * fadeSamplesInv + } + + output[i] = sample + } + case "saw": + for i := 0; i < samples; i++ { + t := float64(i)*sampleRateInv + phase + sample := (2*(t*freq-math.Floor(0.5+t*freq))) * amplitude + + if i < fadeSamples { + sample *= float64(i) * fadeSamplesInv + } else if i >= samples-fadeSamples { + sample *= float64(samples-i) * fadeSamplesInv + } + + output[i] = sample + } + case "triangle": + for i := 0; i < samples; i++ { + t := float64(i)*sampleRateInv + phase + sample := (math.Abs(2*(t*freq-math.Floor(t*freq+0.5)))*2 - 1) * amplitude + + if i < fadeSamples { + sample *= float64(i) * fadeSamplesInv + } else if i >= samples-fadeSamples { + sample *= float64(samples-i) * fadeSamplesInv + } + + output[i] = sample + } + default: + return output + } + + return output +} diff --git a/lib/audio/wav_writer.go b/lib/audio/wav_writer.go new file mode 100644 index 0000000..5d86e0d --- /dev/null +++ b/lib/audio/wav_writer.go @@ -0,0 +1,116 @@ +package audio + +import ( + "encoding/binary" + "math" + "os" +) + +// Represents the header of a WAV file +type WAVHeader struct { + RiffMark [4]byte + FileSize uint32 + WaveMark [4]byte + FmtMark [4]byte + FmtSize uint32 + FormatType uint16 + NumChannels uint16 + SampleRate uint32 + ByteRate uint32 + BlockAlign uint16 + BitsPerSample uint16 + DataMark [4]byte + DataSize uint32 +} + +// Writes the audio data to a WAV file +func (g *Generator) WriteWAV(filename string, samples []float64) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + // Ensure the samples are normalized first + Normalize(samples) + + header := WAVHeader{ + RiffMark: [4]byte{'R', 'I', 'F', 'F'}, + WaveMark: [4]byte{'W', 'A', 'V', 'E'}, + FmtMark: [4]byte{'f', 'm', 't', ' '}, + FmtSize: 16, + FormatType: 1, // PCM + NumChannels: Channels, + SampleRate: uint32(g.sampleRate), + BitsPerSample: Bits, + ByteRate: uint32(g.sampleRate * float64(Channels) * float64(Bits) / 8), + BlockAlign: uint16(Channels * Bits / 8), + DataMark: [4]byte{'d', 'a', 't', 'a'}, + } + + // Calculate sizes - samples are interleaved LRLR... + header.DataSize = uint32(len(samples) * int(Bits) / 8) + header.FileSize = 36 + header.DataSize // 36 is the size of the header up to data + + // Write header + if err := binary.Write(file, binary.LittleEndian, &header); err != nil { + return err + } + + // Write audio data - batch convert and write for better performance + const batchSize = 8192 // Write in 8KB chunks + int16Buffer := make([]int16, batchSize) + + for i := 0; i < len(samples); i += batchSize { + end := i + batchSize + if end > len(samples) { + end = len(samples) + } + + // Convert batch of samples to int16 + for j := i; j < end; j++ { + sample := samples[j] + // Clamp to prevent overflow + if sample > 1.0 { + sample = 1.0 + } else if sample < -1.0 { + sample = -1.0 + } + int16Buffer[j-i] = int16(sample * 32767.0) + } + + // Write entire batch at once + if err := binary.Write(file, binary.LittleEndian, int16Buffer[:end-i]); err != nil { + return err + } + } + + return nil +} + +// Normalizes the audio buffer to prevent clipping +func Normalize(buffer []float64) { + if len(buffer) == 0 { + return + } + + // Find the maximum absolute value in the buffer + maxVal := 0.0 + for _, sample := range buffer { + absVal := math.Abs(sample) + if absVal > maxVal { + maxVal = absVal + } + } + + // If the maximum is very small, don't normalize to avoid amplifying noise + if maxVal < 0.0001 { + return + } + + // Normalize to 90% of maximum to avoid clipping + scale := 0.9 / maxVal + for i := range buffer { + buffer[i] *= scale + } +} diff --git a/lib/config/parser.go b/lib/config/parser.go new file mode 100644 index 0000000..6475851 --- /dev/null +++ b/lib/config/parser.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "git.atri.dad/atridad/muse/schema" + "gopkg.in/yaml.v3" +) + +// Loads a song definition from a YAML file +func LoadSong(filename string) (*schema.Song, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + + var song schema.Song + if err := yaml.Unmarshal(data, &song); err != nil { + return nil, err + } + + return &song, nil +} + +// Saves a song definition to a YAML file +func SaveSong(filename string, song *schema.Song) error { + data, err := yaml.Marshal(song) + if err != nil { + return err + } + + return os.WriteFile(filename, data, os.ModePerm) +} diff --git a/schema/song_schema.go b/schema/song_schema.go new file mode 100644 index 0000000..d09a3a9 --- /dev/null +++ b/schema/song_schema.go @@ -0,0 +1,42 @@ +package schema + +// A simple song with tracks that loop +type Song struct { + Name string `yaml:"name"` + BPM int `yaml:"bpm"` + Length int `yaml:"length"` // Total length in bars + Volume float64 `yaml:"volume"` + Tracks []Track `yaml:"tracks"` +} + +// A simple instrument track that can loop or play once +type Track struct { + Name string `yaml:"name"` + Instrument string `yaml:"instrument"` // sine, square, saw, triangle + Volume float64 `yaml:"volume"` + Pan float64 `yaml:"pan"` // -1.0 to 1.0 + Pattern []string `yaml:"pattern"` // Pattern like ["C4", ".", "E4", "."] + Loop bool `yaml:"loop,omitempty"` // If true, pattern loops. If false, plays once then silence + Effects []Effect `yaml:"effects,omitempty"` // Effects applied to this track +} + +// Defines an audio effect +type Effect struct { + Type string `yaml:"type"` // reverb, delay, filter, etc. + Params map[string]interface{} `yaml:"params"` // Effect-specific parameters +} + +// Returns the song length in beats (bars * 4) +func (s *Song) CalculateTotalLength() float64 { + return float64(s.Length * 4) // 4 beats per bar +} + +// Returns a song with default values +func DefaultSong() Song { + return Song{ + Name: "New Song", + BPM: 120, + Length: 8, // 8 bars default + Volume: 0.7, + } +}