init commit

This commit is contained in:
2026-01-25 17:13:15 -06:00
commit 1bbfc332d8
27 changed files with 2462 additions and 0 deletions

60
internal/audio/formats.go Normal file
View File

@@ -0,0 +1,60 @@
package audio
import (
"fmt"
"path/filepath"
"strings"
)
// AudioFormat represents a supported audio format
type AudioFormat int
const (
FormatUnknown AudioFormat = iota
FormatMP3
FormatWAV
FormatFLAC
FormatOGG
)
// DetectFormat returns the audio format based on file extension
func DetectFormat(path string) AudioFormat {
ext := strings.ToLower(filepath.Ext(path))
switch ext {
case ".mp3":
return FormatMP3
case ".wav":
return FormatWAV
case ".flac":
return FormatFLAC
case ".ogg":
return FormatOGG
default:
return FormatUnknown
}
}
// String returns the format name
func (f AudioFormat) String() string {
switch f {
case FormatMP3:
return "MP3"
case FormatWAV:
return "WAV"
case FormatFLAC:
return "FLAC"
case FormatOGG:
return "OGG"
default:
return "Unknown"
}
}
// ValidateFormat checks if the file format is supported
func ValidateFormat(path string) error {
format := DetectFormat(path)
if format == FormatUnknown {
return fmt.Errorf("unsupported audio format: %s", filepath.Ext(path))
}
return nil
}

313
internal/audio/player.go Normal file
View File

@@ -0,0 +1,313 @@
package audio
import (
"fmt"
"io"
"os"
"sync"
"time"
"github.com/gopxl/beep/v2"
"github.com/gopxl/beep/v2/flac"
"github.com/gopxl/beep/v2/mp3"
"github.com/gopxl/beep/v2/speaker"
"github.com/gopxl/beep/v2/vorbis"
"github.com/gopxl/beep/v2/wav"
)
// Player handles audio playback
type Player struct {
mu sync.Mutex
filePath string
file *os.File
streamer beep.StreamSeekCloser
format beep.Format
ctrl *beep.Ctrl
resampler *beep.Resampler
playing bool
initialized bool
}
// NewPlayer creates a new audio player
func NewPlayer() *Player {
return &Player{}
}
// Load opens an audio file for playback
func (p *Player) Load(path string) error {
p.mu.Lock()
defer p.mu.Unlock()
// Close any existing file
if p.file != nil {
p.streamer.Close()
p.file.Close()
}
format := DetectFormat(path)
if format == FormatUnknown {
return fmt.Errorf("unsupported audio format")
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open audio file: %w", err)
}
var streamer beep.StreamSeekCloser
var audioFormat beep.Format
switch format {
case FormatMP3:
streamer, audioFormat, err = mp3.Decode(file)
case FormatWAV:
streamer, audioFormat, err = wav.Decode(file)
case FormatFLAC:
streamer, audioFormat, err = flac.Decode(file)
case FormatOGG:
streamer, audioFormat, err = vorbis.Decode(file)
}
if err != nil {
file.Close()
return fmt.Errorf("failed to decode audio: %w", err)
}
p.filePath = path
p.file = file
p.streamer = streamer
p.format = audioFormat
p.ctrl = &beep.Ctrl{Streamer: streamer, Paused: true}
// Initialize speaker if not already done
if !p.initialized {
sampleRate := beep.SampleRate(44100)
if err := speaker.Init(sampleRate, sampleRate.N(time.Second/10)); err != nil {
return fmt.Errorf("failed to initialize speaker: %w", err)
}
p.initialized = true
}
// Resample if needed
targetRate := beep.SampleRate(44100)
if audioFormat.SampleRate != targetRate {
p.resampler = beep.Resample(4, audioFormat.SampleRate, targetRate, p.ctrl)
speaker.Play(p.resampler)
} else {
p.resampler = nil
speaker.Play(p.ctrl)
}
return nil
}
// Play starts or resumes playback
func (p *Player) Play() {
p.mu.Lock()
defer p.mu.Unlock()
if p.ctrl == nil {
return
}
speaker.Lock()
p.ctrl.Paused = false
speaker.Unlock()
p.playing = true
}
// Pause pauses playback
func (p *Player) Pause() {
p.mu.Lock()
defer p.mu.Unlock()
if p.ctrl == nil {
return
}
speaker.Lock()
p.ctrl.Paused = true
speaker.Unlock()
p.playing = false
}
// Toggle toggles between play and pause
func (p *Player) Toggle() {
p.mu.Lock()
if p.ctrl == nil {
p.mu.Unlock()
return
}
playing := p.playing
p.mu.Unlock()
if playing {
p.Pause()
} else {
p.Play()
}
}
// IsPlaying returns true if audio is currently playing
func (p *Player) IsPlaying() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.playing
}
// Position returns the current playback position
func (p *Player) Position() time.Duration {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return 0
}
speaker.Lock()
pos := p.format.SampleRate.D(p.streamer.Position())
speaker.Unlock()
return pos
}
// Duration returns the total duration
func (p *Player) Duration() time.Duration {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return 0
}
return p.format.SampleRate.D(p.streamer.Len())
}
// Seek moves to the specified position
func (p *Player) Seek(pos time.Duration) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return nil
}
sample := p.format.SampleRate.N(pos)
if sample < 0 {
sample = 0
}
if sample > p.streamer.Len() {
sample = p.streamer.Len()
}
speaker.Lock()
err := p.streamer.Seek(sample)
speaker.Unlock()
return err
}
// SeekRelative seeks relative to current position
func (p *Player) SeekRelative(delta time.Duration) error {
pos := p.Position()
return p.Seek(pos + delta)
}
// Close releases resources
func (p *Player) Close() {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer != nil {
p.streamer.Close()
}
if p.file != nil {
p.file.Close()
}
}
// Format returns the audio format info
func (p *Player) Format() beep.Format {
p.mu.Lock()
defer p.mu.Unlock()
return p.format
}
// Streamer returns the underlying streamer (for waveform extraction)
func (p *Player) Streamer() beep.StreamSeekCloser {
p.mu.Lock()
defer p.mu.Unlock()
return p.streamer
}
// GetSamples extracts sample data for waveform visualization
func (p *Player) GetSamples(numSamples int) ([]float64, error) {
p.mu.Lock()
defer p.mu.Unlock()
if p.streamer == nil {
return nil, fmt.Errorf("no audio loaded")
}
totalSamples := p.streamer.Len()
if totalSamples == 0 {
return nil, fmt.Errorf("empty audio")
}
samples := make([]float64, numSamples)
samplesPerBucket := totalSamples / numSamples
if samplesPerBucket < 1 {
samplesPerBucket = 1
}
// Save current position
speaker.Lock()
currentPos := p.streamer.Position()
speaker.Unlock()
buf := make([][2]float64, samplesPerBucket)
for i := 0; i < numSamples; i++ {
targetPos := i * samplesPerBucket
if targetPos >= totalSamples {
break
}
speaker.Lock()
p.streamer.Seek(targetPos)
speaker.Unlock()
speaker.Lock()
n, ok := p.streamer.Stream(buf)
speaker.Unlock()
if !ok || n == 0 {
if err, isErr := p.streamer.(interface{ Err() error }); isErr && err.Err() != nil {
if err.Err() != io.EOF {
continue
}
}
continue
}
// Calculate average absolute amplitude
var sum float64
for j := 0; j < n; j++ {
val := (buf[j][0] + buf[j][1]) / 2
if val < 0 {
val = -val
}
sum += val
}
samples[i] = sum / float64(n)
}
// Restore position
speaker.Lock()
p.streamer.Seek(currentPos)
speaker.Unlock()
return samples, nil
}

View File

@@ -0,0 +1,36 @@
package audio
// WaveformData holds pre-computed waveform samples
type WaveformData struct {
Samples []float64
MaxValue float64
}
// NewWaveformData creates waveform data from raw samples
func NewWaveformData(samples []float64) *WaveformData {
wd := &WaveformData{
Samples: samples,
}
// Find max value for normalization
for _, s := range samples {
if s > wd.MaxValue {
wd.MaxValue = s
}
}
if wd.MaxValue == 0 {
wd.MaxValue = 1 // Avoid division by zero
}
return wd
}
// Normalized returns samples normalized to 0-1 range
func (w *WaveformData) Normalized() []float64 {
normalized := make([]float64, len(w.Samples))
for i, s := range w.Samples {
normalized[i] = s / w.MaxValue
}
return normalized
}