[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:
parent
0d019ec696
commit
1515b4113f
3
Makefile
3
Makefile
|
|
@ -1,10 +1,11 @@
|
||||||
.PHONY: all
|
.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/script-decode: script/*.go
|
||||||
bin/sbutil: rom/*.go
|
bin/sbutil: rom/*.go
|
||||||
bin/just-stats: script/*.go
|
bin/just-stats: script/*.go
|
||||||
|
bin/sbx2wav: rom/*.go audio/*.go
|
||||||
|
|
||||||
bin/%: cmd/%.go
|
bin/%: cmd/%.go
|
||||||
go build -o $@ $<
|
go build -o $@ $<
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
11
go.mod
|
|
@ -3,8 +3,13 @@ module git.zorchenhimer.com/Zorchenhimer/go-studybox
|
||||||
go 1.24.1
|
go 1.24.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/alexflint/go-arg v1.5.1
|
github.com/alexflint/go-arg v1.6.0
|
||||||
github.com/zorchenhimer/go-retroimg v0.0.0-20251108020316-705ea2ebacd6
|
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
14
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.6.0 h1:wPP9TwTPO54fUVQl4nZoxbFfKCcy5E6HBCumj1XVRSo=
|
||||||
github.com/alexflint/go-arg v1.5.1/go.mod h1:A7vTJzvjoaSTypg4biM5uYNTkJ27SkNTArtYXnlqVO8=
|
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 h1:WR7JPKkeNpnYIOfHRa7ivM21aWAdHD0gEWHCx+WQBRw=
|
||||||
github.com/alexflint/go-scalar v1.2.0/go.mod h1:LoFvNMqS1CPrMVltza4LvnGKhaSpc3oyLEBUZVhhS2o=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
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-20251111010417-7299e86df5a9 h1:tDALzDZa+sEzKDNDtPkbWIIxcfR3lB5X6ghUieaPLnE=
|
||||||
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/go.mod h1:iQUJQkvvbgycl7TS2OWdSC0+kHYypOASX129xmnv+SE=
|
||||||
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
|
gopkg.in/yaml.v3 v3.0.0 h1:hjy8E9ON/egN1tAYqKb61G10WtihqetD4sz2H+8nIeA=
|
||||||
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue