[sbx2wav] Start adding a tool to generate tape audio

This tool will take a .studybox ROM and encode the data in an MFM audio
wave.  Currently, this just handles the data channel and not the
recorded audio.  Output is a mono .wav file.

Also, some timing and settings are probably wrong. (Generated audio
doesn't decode with Sour's decoder)
This commit is contained in:
Zorchenhimer 2025-11-23 18:24:25 -05:00
parent 0d019ec696
commit 1515b4113f
Signed by: Zorchenhimer
GPG Key ID: 70A1AB767AAB9C20
6 changed files with 373 additions and 8 deletions

View File

@ -1,10 +1,11 @@
.PHONY: all
all: bin/script-decode bin/sbutil bin/just-stats bin/extract-imgs
all: bin/script-decode bin/sbutil bin/just-stats bin/extract-imgs bin/sbx2wav
bin/script-decode: script/*.go
bin/sbutil: rom/*.go
bin/just-stats: script/*.go
bin/sbx2wav: rom/*.go audio/*.go
bin/%: cmd/%.go
go build -o $@ $<

55
audio/bitdata.go Normal file
View File

@ -0,0 +1,55 @@
package audio
type BitData struct {
data []byte
next int
current byte
left int // bits left in current
}
func NewBitData(data []byte) *BitData {
if len(data) == 0 {
panic("no data")
}
return &BitData{
data: data,
next: 1,
current: data[0],
left: 7,
}
}
// Returns the bit in the lowest position, and end of data. false if nothing left.
func (b *BitData) Next() (byte, bool) {
if b.left < 0 {
if len(b.data) <= b.next {
return 0, false
}
b.current = b.data[b.next]
b.next++
b.left = 7
return 0, true
}
ret := (b.current >> b.left) & 0x01
b.left--
return ret, true
}
func (b *BitData) Peek() byte {
left := b.left
current := b.current
if left < 0 {
if len(b.data) <= b.next {
return 0
}
current = b.data[b.next]
left = 7
}
return (current >> left) & 0x01
}

247
audio/encode.go Normal file
View File

@ -0,0 +1,247 @@
package audio
// TODO:
// - Stereo with the recorded audio, not just data.
// - Configurable lead-in silence (from start of audio)
// - Configurable segment gap lengths (silence between segments)
import (
"math"
"slices"
"io"
"bytes"
"fmt"
"github.com/go-audio/audio"
"github.com/go-audio/wav"
"git.zorchenhimer.com/Zorchenhimer/go-studybox/rom"
)
const (
SampleRate uint32 = 44_100 // TODO: verify sample rate with SBX audio
Amplitude int = 16_000
)
var (
BitRate int = 4890
)
func EncodeRom(w io.WriteSeeker, sbx *rom.StudyBox) error {
if sbx == nil {
return fmt.Errorf("nil rom")
}
if sbx.Audio == nil {
return fmt.Errorf("Missing audio")
}
if sbx.Audio.Format != rom.AUDIO_WAV {
return fmt.Errorf("unsupported audio format: %s", sbx.Audio)
}
if len(sbx.Data.Pages) == 0 {
return fmt.Errorf("no pages")
}
if len(sbx.Data.Pages[0].Packets) == 0 {
return fmt.Errorf("no packets")
}
wavreader := bytes.NewReader(sbx.Audio.Data)
decoder := wav.NewDecoder(wavreader)
if !decoder.IsValidFile() {
return fmt.Errorf(".studybox file does not contain a valid wav file")
}
decoder.ReadInfo()
if decoder.SampleRate != SampleRate {
return fmt.Errorf("SampleRate mismatch. Expected %d; found %d", SampleRate, decoder.SampleRate)
}
afmt := &audio.Format{
NumChannels: 1,
SampleRate: int(decoder.SampleRate),
}
writer := wav.NewEncoder(
w,
int(decoder.SampleRate),
int(decoder.BitDepth),
int(decoder.NumChans),
1)
defer writer.Close()
var err error
runningSamples := int64(0)
prevPageLeadIn := 0
for _, page := range sbx.Data.Pages {
if prevPageLeadIn > page.AudioOffsetLeadIn {
return fmt.Errorf("out of order pages (AudioOffsetLeadIn)")
}
prevPageLeadIn = page.AudioOffsetLeadIn
padLen := int64(page.AudioOffsetLeadIn)-runningSamples
fmt.Printf("padLen: %d = %d - %d\n", padLen, int64(page.AudioOffsetLeadIn), runningSamples)
if padLen < 0 {
padLen = 100_000
}
err = generatePadding(writer, afmt, padLen)
if err != nil {
return fmt.Errorf("generatePadding() error: %w", err)
}
runningSamples += padLen
sampleCount, err := encodePage(writer, afmt, page)
if err != nil {
return fmt.Errorf("encodePage() error: %w", err)
}
runningSamples += sampleCount
}
writer.Close()
return nil
}
func generatePadding(writer *wav.Encoder, afmt *audio.Format, length int64) error {
fmt.Println("generatePadding() length:", length)
buf := &audio.IntBuffer{
Format: afmt,
SourceBitDepth: writer.BitDepth,
Data: make([]int, length),
}
return writer.Write(buf)
}
func encodePage(writer *wav.Encoder, afmt *audio.Format, page *rom.Page) (int64, error) {
runningFract := float64(0)
//dataLeadLen := int(float64(page.AudioOffsetData - page.AudioOffsetLeadIn) / float64(83))
samplesPerFlux := float64(writer.SampleRate) / float64(BitRate) / 2
fmt.Println("samplesPerFlux:", samplesPerFlux)
dataLeadLen := (page.AudioOffsetData - page.AudioOffsetLeadIn) / int((samplesPerFlux * 9)) / 2
fmt.Println("dataLeadLen:", dataLeadLen-9)
lead := &rawData{
data: slices.Repeat([]byte{0}, dataLeadLen-9),
pageOffset: int64(page.AudioOffsetData),
}
fmt.Println("lead length:", len(lead.data))
runningFract = lead.encode(samplesPerFlux, 0)
data := []*rawData{}
data = append(data, lead)
for _, packet := range page.Packets {
d := &rawData{
data: packet.RawBytes(),
pageOffset: int64(page.AudioOffsetData),
}
runningFract = d.encode(samplesPerFlux, runningFract)
data = append(data, d)
}
sampleCount := int64(0)
for _, d := range data {
buf := &audio.IntBuffer{
Format: afmt,
SourceBitDepth: writer.BitDepth,
Data: d.samples,
}
err := writer.Write(buf)
if err != nil {
return sampleCount, err
}
sampleCount += int64(len(d.samples))
}
return sampleCount, nil
}
type rawData struct {
pageOffset int64
realOffset int64
data []byte
samples []int
}
func (d *rawData) encode(samplesPerFlux, runningFract float64) float64 {
bits := NewBitData(d.data)
flux := []byte{}
prev := 0
for {
bit, more := bits.Next()
if !more {
break
}
peek := bits.Peek()
if bit == 1 {
flux = append(flux, 1)
} else {
flux = append(flux, 0)
}
if bit == peek && bit == 0 {
// clock flux change
flux = append(flux, 1)
} else {
// no clock flux change
flux = append(flux, 0)
}
}
first := true
for _, f := range flux {
amp := Amplitude
if f == 1 {
if prev <= 0 {
prev = 1
} else {
prev = -1
}
//amp = int(float64(Amplitude) * 1.5)
first = true
}
//if i == 0 {
// amp = Amplitude + 1000
//} else {
// amp = Amplitude
//}
spf, fract := math.Modf(samplesPerFlux)
runningFract += fract
if runningFract >= 1 {
runningFract -= 1
spf++
}
//d.samples = append(d.samples, slices.Repeat([]int{prev*Amplitude}, int(spf))...)
for i := 0; i < int(spf); i++ {
if first {
d.samples = append(d.samples, prev*int(float64(amp)*1.5))
}
d.samples = append(d.samples, prev*amp)
first = false
//amp -= 500
}
//d.samples = append(d.samples, slices.Repeat([]int{prev*Amplitude}, int(samplesPerFlux))...)
first = false
}
return runningFract
}

51
cmd/sbx2wav.go Normal file
View File

@ -0,0 +1,51 @@
package main
import (
"fmt"
"os"
"github.com/alexflint/go-arg"
"git.zorchenhimer.com/Zorchenhimer/go-studybox/audio"
"git.zorchenhimer.com/Zorchenhimer/go-studybox/rom"
)
type Arguments struct {
Input string `arg:"positional,required"`
Output string `arg:"positional,required"`
BitRate int `arg:"--bit-rate", default:"4790"` // value found by trial and error
}
func run(args *Arguments) error {
sbx, err := rom.ReadFile(args.Input)
if err != nil {
return err
}
output, err := os.Create(args.Output)
if err != nil {
return err
}
defer output.Close()
audio.BitRate = args.BitRate
err = audio.EncodeRom(output, sbx)
if err != nil {
return fmt.Errorf("Encode error: %w", err)
}
return nil
}
func main() {
args := &Arguments{}
arg.MustParse(args)
err := run(args)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}

11
go.mod
View File

@ -3,8 +3,13 @@ module git.zorchenhimer.com/Zorchenhimer/go-studybox
go 1.24.1
require (
github.com/alexflint/go-arg v1.5.1
github.com/zorchenhimer/go-retroimg v0.0.0-20251108020316-705ea2ebacd6
github.com/alexflint/go-arg v1.6.0
github.com/go-audio/audio v1.0.0
github.com/go-audio/wav v1.1.0
github.com/zorchenhimer/go-retroimg v0.0.0-20251111010417-7299e86df5a9
)
require github.com/alexflint/go-scalar v1.2.0 // indirect
require (
github.com/alexflint/go-scalar v1.2.0 // indirect
github.com/go-audio/riff v1.0.0 // indirect
)

14
go.sum
View File

@ -1,15 +1,21 @@
github.com/alexflint/go-arg v1.5.1 h1:nBuWUCpuRy0snAG+uIJ6N0UvYxpxA0/ghA/AaHxlT8Y=
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
github.com/alexflint/go-arg v1.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo=
github.com/alexflint/go-arg v1.6.0/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
github.com/alexflint/go-scalar v1.2.0 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-audio/audio v1.0.0 h1:zS9vebldgbQqktK4H0lUqWrG8P0NxCJVqcj7ZpNnwd4=
github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs=
github.com/go-audio/riff v1.0.0 h1:d8iCGbDvox9BfLagY94fBynxSPHO80LmZCaOsmKxokA=
github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498=
github.com/go-audio/wav v1.1.0 h1:jQgLtbqBzY7G+BM8fXF7AHUk1uHUviWS4X39d5rsL2g=
github.com/go-audio/wav v1.1.0/go.mod h1:mpe9qfwbScEbkd8uybLuIpTgHyrISw/OTuvjUW2iGtE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/zorchenhimer/go-retroimg v0.0.0-20251108020316-705ea2ebacd6 h1:94b43etKen/R0Ga+lxgfSMlYQicwsMvlFBizMOQ3loc=
github.com/zorchenhimer/go-retroimg v0.0.0-20251108020316-705ea2ebacd6/go.mod h1:iQUJQkvvbgycl7TS2OWdSC0+kHYypOASX129xmnv+SE=
github.com/zorchenhimer/go-retroimg v0.0.0-20251111010417-7299e86df5a9 h1:tDALzDZa+sEzKDNDtPkbWIIxcfR3lB5X6ghUieaPLnE=
github.com/zorchenhimer/go-retroimg v0.0.0-20251111010417-7299e86df5a9/go.mod h1:iQUJQkvvbgycl7TS2OWdSC0+kHYypOASX129xmnv+SE=
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=