0.1.0 - Initial commit
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
output/
|
||||||
|
.DS_Store
|
||||||
BIN
Example Song.wav
Normal file
BIN
Example Song.wav
Normal file
Binary file not shown.
46
README.md
46
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.
|
||||||
|
|
||||||
|
|||||||
96
cmd/muse/main.go
Normal file
96
cmd/muse/main.go
Normal file
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
41
examples/example.yaml
Normal file
41
examples/example.yaml
Normal file
@@ -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
|
||||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
module git.atri.dad/atridad/muse
|
||||||
|
|
||||||
|
go 1.24.3
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
4
go.sum
Normal file
4
go.sum
Normal file
@@ -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=
|
||||||
133
lib/audio/effects.go
Normal file
133
lib/audio/effects.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
129
lib/audio/generator.go
Normal file
129
lib/audio/generator.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
44
lib/audio/notes.go
Normal file
44
lib/audio/notes.go
Normal file
@@ -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))
|
||||||
|
}
|
||||||
80
lib/audio/oscillator.go
Normal file
80
lib/audio/oscillator.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
116
lib/audio/wav_writer.go
Normal file
116
lib/audio/wav_writer.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
33
lib/config/parser.go
Normal file
33
lib/config/parser.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
42
schema/song_schema.go
Normal file
42
schema/song_schema.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user