diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index e2dfa95..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "customizations": { - "vscode": { - "extensions": [ - "golang.Go", - "github.copilot" - ] - } - }, - "features": { - "ghcr.io/devcontainers/features/sshd:1": { - "version": "latest" - } - }, - "image": "mcr.microsoft.com/devcontainers/go" -} diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 0845eaf..c5456fc 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -11,10 +11,15 @@ jobs: steps: - uses: actions/checkout@v3 + - name: install alsa-utils + run: | + sudo apt-get -y update + sudo apt-get -y install alsa-utils libasound2-dev + - name: Set up Go uses: actions/setup-go@v4 with: - go-version: '1.20' + go-version: '1.24' - name: Build run: make setup build diff --git a/.gitignore b/.gitignore index 824d772..f8913c9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ # Dependency directories (remove the comment below to include it) vendor/ -dist/ \ No newline at end of file +dist/ +impulse.log diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5f5c41c --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: all +all: setup fmt build test + +.PHONY: setup +setup: + go mod download + go mod tidy + +.PHONY: build +build: clean + @echo "==> Building Packages <==" + go build -v ./... + @echo "==> Building cli <==" + go build -o dist/cli ./cmd/impulse/... + +.PHONY: test +test: + @echo "==> running Go tests <==" + go test -race ./... + +.PHONY: fmt +fmt: + go fmt ./... + +.PHONY: clean +clean: + @echo "==> Cleaning dist/ <==" + rm -fr dist/* diff --git a/cmd/impulse/loader.go b/cmd/impulse/loader.go index 72f0715..fa6c33f 100644 --- a/cmd/impulse/loader.go +++ b/cmd/impulse/loader.go @@ -20,4 +20,4 @@ func loadModule(filePath string) (module.Module, error) { return nil, fmt.Errorf("failed to load module: %v", err) } return m, nil -} \ No newline at end of file +} diff --git a/cmd/impulse/main.go b/cmd/impulse/main.go index b5c76b4..a09e005 100644 --- a/cmd/impulse/main.go +++ b/cmd/impulse/main.go @@ -85,4 +85,4 @@ func main() { if err := app.Run(os.Args); err != nil { log.Fatal(err) } -} \ No newline at end of file +} diff --git a/examples/volume-envelope.xm b/examples/volume-envelope.xm new file mode 100644 index 0000000..395ce88 Binary files /dev/null and b/examples/volume-envelope.xm differ diff --git a/impulse.log b/impulse.log deleted file mode 100644 index e127f8e..0000000 --- a/impulse.log +++ /dev/null @@ -1 +0,0 @@ -2025/07/29 16:00:28 Starting pprof agent on localhost:6060 diff --git a/internal/player/audioplayer.go b/internal/player/audioplayer.go index cf3d08b..de02fc8 100644 --- a/internal/player/audioplayer.go +++ b/internal/player/audioplayer.go @@ -130,15 +130,15 @@ func (w *WavPlayer) writeWavHeader() { // "fmt " sub-chunk w.writer.Write([]byte("fmt ")) - binary.Write(w.writer, binary.LittleEndian, uint32(16)) // Sub-chunk size - binary.Write(w.writer, binary.LittleEndian, uint16(1)) // Audio format (PCM) - binary.Write(w.writer, binary.LittleEndian, uint16(w.opts.NumChannels)) // Num channels - binary.Write(w.writer, binary.LittleEndian, uint32(w.opts.SampleRate)) // Sample rate + binary.Write(w.writer, binary.LittleEndian, uint32(16)) // Sub-chunk size + binary.Write(w.writer, binary.LittleEndian, uint16(1)) // Audio format (PCM) + binary.Write(w.writer, binary.LittleEndian, uint16(w.opts.NumChannels)) // Num channels + binary.Write(w.writer, binary.LittleEndian, uint32(w.opts.SampleRate)) // Sample rate binary.Write(w.writer, binary.LittleEndian, uint32(w.opts.SampleRate*w.opts.NumChannels*w.opts.BitDepth)) // Byte rate - binary.Write(w.writer, binary.LittleEndian, uint16(w.opts.NumChannels*w.opts.BitDepth)) // Block align - binary.Write(w.writer, binary.LittleEndian, uint16(w.opts.BitDepth*8)) // Bits per sample + binary.Write(w.writer, binary.LittleEndian, uint16(w.opts.NumChannels*w.opts.BitDepth)) // Block align + binary.Write(w.writer, binary.LittleEndian, uint16(w.opts.BitDepth*8)) // Bits per sample // "data" sub-chunk w.writer.Write([]byte("data")) binary.Write(w.writer, binary.LittleEndian, uint32(0)) // Placeholder for data size -} \ No newline at end of file +} diff --git a/internal/player/protracker_ticker.go b/internal/player/protracker_ticker.go index 1f76d71..55d0751 100644 --- a/internal/player/protracker_ticker.go +++ b/internal/player/protracker_ticker.go @@ -2,7 +2,6 @@ package player import "github.com/jesseward/impulse/pkg/module" - var periodTable = [16 * 36]uint16{ 856, 808, 762, 720, 678, 640, 604, 570, 538, 508, 480, 453, 428, 404, 381, 360, 339, 320, 302, 285, 269, 254, 240, 226, 214, 202, 190, 180, 170, 160, 151, 143, 135, 127, 120, 113, 850, 802, 757, 715, 674, 637, 601, 567, 535, 505, 477, 450, 425, 401, 379, 357, 337, 318, 300, 284, 268, 253, 239, 225, 213, 201, 189, 179, 169, 159, 150, 142, 134, 126, 119, 113, @@ -356,4 +355,4 @@ func (t *ProtrackerTicker) RenderChannelTick(p *Player, state *channelState, tic state.samplePos += step } } -} \ No newline at end of file +} diff --git a/internal/ui/waveform.go b/internal/ui/waveform.go index 16da6cd..12303eb 100644 --- a/internal/ui/waveform.go +++ b/internal/ui/waveform.go @@ -21,7 +21,7 @@ func newWaveformModel(s module.Sample, w, h int) waveformModel { func (m waveformModel) View() string { title := titleStyle.Render("Sample '" + m.sample.Name() + "'") - waveform := noteStyle.Render(m.sample.AsciiWaveform(m.width/2, m.height/2)) + waveform := noteStyle.Render(module.AsciiWaveform(m.sample, m.width/2, m.height/2)) // Calculate the size of the dialog box (50% of screen) and add some padding dialogWidth := (m.width / 2) + 6 diff --git a/pkg/module/module.go b/pkg/module/module.go index beb8ca4..2a9be94 100644 --- a/pkg/module/module.go +++ b/pkg/module/module.go @@ -1,6 +1,10 @@ package module -import "fmt" +import ( + "fmt" + "math" + "strings" +) const ( EmptyNote = "..." @@ -48,7 +52,6 @@ type Sample interface { IsPingPong() bool RelativeNote() int8 Panning() byte - AsciiWaveform(width, height int) string } type Effect struct { @@ -63,3 +66,77 @@ func (e *Effect) EffectString() string { } return fmt.Sprintf("%X%X%X", e.Command, e.X, e.Y) } + +// renderWaveform generates a multi-line ASCII representation of the audio data. +// It works by downsampling the audio data to fit the specified width and height. +// +// The process is as follows: +// 1. The audio data is divided into a number of "buckets" equal to the width of the view. +// 2. For each bucket, the minimum (trough) and maximum (peak) sample values are found. +// 3. These peak and trough values are then scaled to the height of the view. +// 4. A vertical bar is drawn from the trough to the peak for each bucket, creating a solid, +// filled waveform. +func AsciiWaveform(s Sample, width, height int) string { + + if len(s.Data()) == 0 || width <= 0 || height <= 0 { + return "" + } + + // Create a 2D grid for the waveform display + grid := make([][]rune, height) + for i := range grid { + grid[i] = make([]rune, width) + for j := range grid[i] { + grid[i][j] = ' ' + } + } + + bucketSize := float64(len(s.Data())) / float64(width) + halfHeight := float64(height) / 2.0 + + for i := range width { + start := int(float64(i) * bucketSize) + end := min(int(float64(i+1)*bucketSize), len(s.Data())) + if start >= end { + continue + } + + bucket := s.Data()[start:end] + var minVal, maxVal int16 = 0, 0 + for _, s := range bucket { + if s < minVal { + minVal = s + } + if s > maxVal { + maxVal = s + } + } + + // Normalize and scale to the view height + yMax := int(math.Round(float64(maxVal)/32767.0*halfHeight + halfHeight)) + yMin := int(math.Round(float64(minVal)/32767.0*halfHeight + halfHeight)) + + // Clamp values to be within the grid + if yMax >= height { + yMax = height - 1 + } + if yMin < 0 { + yMin = 0 + } + + // Draw the vertical bar for the current bucket + for y := yMin; y <= yMax; y++ { + if y >= 0 && y < height { + grid[y][i] = '█' + } + } + } + + // Convert the grid to a single string + var builder strings.Builder + for y := range height { + builder.WriteString(string(grid[y])) + builder.WriteRune('\n') + } + return builder.String() +} diff --git a/pkg/protracker/protracker.go b/pkg/protracker/protracker.go index 2edba17..9bd9d91 100644 --- a/pkg/protracker/protracker.go +++ b/pkg/protracker/protracker.go @@ -1,7 +1,6 @@ package protracker import ( - "math" "strings" "github.com/jesseward/impulse/pkg/module" @@ -166,88 +165,10 @@ func (s *Sample) Panning() byte { return 128 // Protracker is mono } - func (s *Sample) LoopEnd() uint32 { return uint32(s.loopStart + s.loopLength) } -// renderWaveform generates a multi-line ASCII representation of the audio data. -// It works by downsampling the audio data to fit the specified width and height. -// -// The process is as follows: -// 1. The audio data is divided into a number of "buckets" equal to the width of the view. -// 2. For each bucket, the minimum (trough) and maximum (peak) sample values are found. -// 3. These peak and trough values are then scaled to the height of the view. -// 4. A vertical bar is drawn from the trough to the peak for each bucket, creating a solid, -// filled waveform. -func (s *Sample) AsciiWaveform(width, height int) string { - - if len(s.data) == 0 || width <= 0 || height <= 0 { - return "" - } - - // Create a 2D grid for the waveform display - grid := make([][]rune, height) - for i := range grid { - grid[i] = make([]rune, width) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - bucketSize := float64(len(s.data)) / float64(width) - halfHeight := float64(height) / 2.0 - - for i := 0; i < width; i++ { - start := int(float64(i) * bucketSize) - end := int(float64(i+1) * bucketSize) - if end > len(s.data) { - end = len(s.data) - } - if start >= end { - continue - } - - bucket := s.data[start:end] - var minVal, maxVal int16 = 0, 0 - for _, s := range bucket { - if s < minVal { - minVal = s - } - if s > maxVal { - maxVal = s - } - } - - // Normalize and scale to the view height - yMax := int(math.Round(float64(maxVal)/32767.0*halfHeight + halfHeight)) - yMin := int(math.Round(float64(minVal)/32767.0*halfHeight + halfHeight)) - - // Clamp values to be within the grid - if yMax >= height { - yMax = height - 1 - } - if yMin < 0 { - yMin = 0 - } - - // Draw the vertical bar for the current bucket - for y := yMin; y <= yMax; y++ { - if y >= 0 && y < height { - grid[y][i] = '█' - } - } - } - - // Convert the grid to a single string - var builder strings.Builder - for y := 0; y < height; y++ { - builder.WriteString(string(grid[y])) - builder.WriteRune('\n') - } - return builder.String() -} - func (c *ChannelSequence) GetChannel() (int, int, module.Effect) { return int(c.SampleNumber), int(c.Period), c.Effect } diff --git a/pkg/s3m/s3m.go b/pkg/s3m/s3m.go index dfd39bf..2bf8acd 100644 --- a/pkg/s3m/s3m.go +++ b/pkg/s3m/s3m.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "fmt" "io" - "math" "os" "strings" @@ -135,71 +134,6 @@ func (inst *Instrument) Data() []int16 { return inst.data } -func (inst *Instrument) AsciiWaveform(width, height int) string { - if len(inst.data) == 0 || width <= 0 || height <= 0 { - return "" - } - - grid := make([][]rune, height) - for i := range grid { - grid[i] = make([]rune, width) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - bucketSize := float64(len(inst.data)) / float64(width) - halfHeight := float64(height) / 2.0 - - for i := 0; i < width; i++ { - start := int(float64(i) * bucketSize) - end := int(float64(i+1) * bucketSize) - if end > len(inst.data) { - end = len(inst.data) - } - if start >= end { - continue - } - - bucket := inst.data[start:end] - var minVal, maxVal int16 = 0, 0 - for _, s := range bucket { - if s < minVal { - minVal = s - } - if s > maxVal { - maxVal = s - } - } - - // Normalize and scale to the view height - yMax := int(math.Round(float64(maxVal)/32767.0*halfHeight + halfHeight)) - yMin := int(math.Round(float64(minVal)/32767.0*halfHeight + halfHeight)) - - // Clamp values to be within the grid - if yMax >= height { - yMax = height - 1 - } - if yMin < 0 { - yMin = 0 - } - - // Draw the vertical bar for the current bucket - for y := yMin; y <= yMax; y++ { - if y >= 0 && y < height { - grid[y][i] = '█' - } - } - } - - var builder strings.Builder - for y := 0; y < height; y++ { - builder.WriteString(string(grid[y])) - builder.WriteRune('\n') - } - return builder.String() -} - var noteTable = [12]string{"C-", "C#", "D-", "D#", "E-", "F-", "F#", "G-", "G#", "A-", "A#", "B-"} func NoteToString(note byte) string { diff --git a/pkg/xm/xm.go b/pkg/xm/xm.go index b2b591e..d6f24ca 100644 --- a/pkg/xm/xm.go +++ b/pkg/xm/xm.go @@ -5,7 +5,6 @@ import ( "encoding/binary" "fmt" "io" - "math" "strings" "github.com/jesseward/impulse/pkg/module" @@ -542,72 +541,6 @@ func (s *Sample) Panning() byte { return s.panning } - func (s *Sample) LoopEnd() uint32 { return s.loopStart + s.loopLength } - -func (s *Sample) AsciiWaveform(width, height int) string { - if len(s.data) == 0 || width <= 0 || height <= 0 { - return "" - } - - grid := make([][]rune, height) - for i := range grid { - grid[i] = make([]rune, width) - for j := range grid[i] { - grid[i][j] = ' ' - } - } - - bucketSize := float64(len(s.data)) / float64(width) - halfHeight := float64(height) / 2.0 - - for i := 0; i < width; i++ { - start := int(float64(i) * bucketSize) - end := int(float64(i+1) * bucketSize) - if end > len(s.data) { - end = len(s.data) - } - if start >= end { - continue - } - - bucket := s.data[start:end] - var minVal, maxVal int16 = 0, 0 - for _, s := range bucket { - if s < minVal { - minVal = s - } - if s > maxVal { - maxVal = s - } - } - - // Normalize and scale to the view height - yMax := int(math.Round(float64(maxVal)/32767.0*halfHeight + halfHeight)) - yMin := int(math.Round(float64(minVal)/32767.0*halfHeight + halfHeight)) - - // Clamp values to be within the grid - if yMax >= height { - yMax = height - 1 - } - if yMin < 0 { - yMin = 0 - } - - // Draw the vertical bar for the current bucket - for y := yMin; y <= yMax; y++ { - if y >= 0 && y < height { - grid[y][i] = '█' - } - } - } - - var builder strings.Builder - for y := 0; y < height; y++ { - builder.WriteString(string(grid[y])) - builder.WriteRune('\n') - } - return builder.String() -} diff --git a/pkg/xm/xm_test.go b/pkg/xm/xm_test.go index 95bafce..a9f4b5d 100644 --- a/pkg/xm/xm_test.go +++ b/pkg/xm/xm_test.go @@ -18,4 +18,4 @@ func TestRead(t *testing.T) { if err != nil { t.Fatalf("Read() failed: %v", err) } -} \ No newline at end of file +}