[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
|
||||
|
||||
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 $@ $<
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
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
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.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=
|
||||
|
|
|
|||
Loading…
Reference in New Issue