diff --git a/Makefile b/Makefile index 23c402f..050d983 100644 --- a/Makefile +++ b/Makefile @@ -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 $@ $< diff --git a/audio/bitdata.go b/audio/bitdata.go new file mode 100644 index 0000000..c2272ed --- /dev/null +++ b/audio/bitdata.go @@ -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 +} + diff --git a/audio/encode.go b/audio/encode.go new file mode 100644 index 0000000..49520bb --- /dev/null +++ b/audio/encode.go @@ -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 +} diff --git a/cmd/sbx2wav.go b/cmd/sbx2wav.go new file mode 100644 index 0000000..9550cc3 --- /dev/null +++ b/cmd/sbx2wav.go @@ -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) + } +} diff --git a/go.mod b/go.mod index bdc2232..ead5e61 100644 --- a/go.mod +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum index 4d1cf66..db8dafa 100644 --- a/go.sum +++ b/go.sum @@ -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=