Skip to content
This repository was archived by the owner on Dec 24, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions .devcontainer/devcontainer.json

This file was deleted.

7 changes: 6 additions & 1 deletion .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,5 @@
# Dependency directories (remove the comment below to include it)
vendor/

dist/
dist/
impulse.log
28 changes: 28 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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/*
2 changes: 1 addition & 1 deletion cmd/impulse/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ func loadModule(filePath string) (module.Module, error) {
return nil, fmt.Errorf("failed to load module: %v", err)
}
return m, nil
}
}
2 changes: 1 addition & 1 deletion cmd/impulse/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,4 @@ func main() {
if err := app.Run(os.Args); err != nil {
log.Fatal(err)
}
}
}
Binary file added examples/volume-envelope.xm
Binary file not shown.
1 change: 0 additions & 1 deletion impulse.log

This file was deleted.

14 changes: 7 additions & 7 deletions internal/player/audioplayer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
3 changes: 1 addition & 2 deletions internal/player/protracker_ticker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -356,4 +355,4 @@ func (t *ProtrackerTicker) RenderChannelTick(p *Player, state *channelState, tic
state.samplePos += step
}
}
}
}
2 changes: 1 addition & 1 deletion internal/ui/waveform.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 79 additions & 2 deletions pkg/module/module.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
package module

import "fmt"
import (
"fmt"
"math"
"strings"
)

const (
EmptyNote = "..."
Expand Down Expand Up @@ -48,7 +52,6 @@ type Sample interface {
IsPingPong() bool
RelativeNote() int8
Panning() byte
AsciiWaveform(width, height int) string
}

type Effect struct {
Expand All @@ -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()
}
79 changes: 0 additions & 79 deletions pkg/protracker/protracker.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package protracker

import (
"math"
"strings"

"github.com/jesseward/impulse/pkg/module"
Expand Down Expand Up @@ -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
}
Loading