0.1.0 - Initial commit

This commit is contained in:
2025-09-03 16:30:44 -06:00
parent 2454a2157b
commit 8a4d629eb8
15 changed files with 770 additions and 1 deletions

133
lib/audio/effects.go Normal file
View 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
View 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
View 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
View 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
View 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
}
}