commit 7ff7fdd0ccf1f8f5aed18e879cbc6614bc6096cb Author: Sebastiaan de Schaetzen Date: Mon Apr 20 13:56:00 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f7c409d --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Compiled binary (module name: bell-silencer) +bell-silencer + +# Go build/test artifacts +*.test +*.out + +# macOS +.DS_Store diff --git a/bells.wav b/bells.wav new file mode 100644 index 0000000..9ad0fb2 Binary files /dev/null and b/bells.wav differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2d8b4f1 --- /dev/null +++ b/go.mod @@ -0,0 +1,13 @@ +module github.com/bellpilot/bell-silencer + +go 1.26.2 + +require ( + github.com/creack/pty v1.1.24 // indirect + github.com/ebitengine/oto/v3 v3.4.0 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/gordonklaus/portaudio v0.0.0-20260203164431-765aa7dfa631 // indirect + github.com/hajimehoshi/oto/v2 v2.4.3 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f748be1 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= +github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= +github.com/ebitengine/oto/v3 v3.4.0 h1:br0PgASsEWaoWn38b2Goe7m1GKFYfNgnsjSd5Gg+/bQ= +github.com/ebitengine/oto/v3 v3.4.0/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= +github.com/ebitengine/purego v0.4.1 h1:atcZEBdukuoClmy7TI89amtqAsJUzDQyY/JU7HaK+io= +github.com/ebitengine/purego v0.4.1/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gordonklaus/portaudio v0.0.0-20260203164431-765aa7dfa631 h1:8TBHztmhDfAAg34yddptshinXBtDQwgKGlMfdtSFETw= +github.com/gordonklaus/portaudio v0.0.0-20260203164431-765aa7dfa631/go.mod h1:esZFQEUwqC+l76f2R8bIWSwXMaPbp79PppwZ1eJhFco= +github.com/hajimehoshi/oto/v2 v2.4.3 h1:E+vVhzF2WHuw/UK+aLQh1Spqj+thgsAAg4rbSx+JySI= +github.com/hajimehoshi/oto/v2 v2.4.3/go.mod h1:Yx9MTrWMeSS6MqkjacVZAicmJ1bqA1SlgCQmk3ybx1E= +golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= +golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fde6a3c --- /dev/null +++ b/main.go @@ -0,0 +1,487 @@ +package main + +import ( + "bytes" + "embed" + "encoding/binary" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/creack/pty" + "github.com/ebitengine/oto/v3" + "golang.org/x/term" +) + +//go:embed bells.wav +var bellFile embed.FS + +// --- Audio engine --- + +type audioEngine struct { + once sync.Once + ctx *oto.Context + pcm []byte + initErr error + bellCh chan struct{} +} + +var engine = &audioEngine{ + bellCh: make(chan struct{}, 1), +} + +func (e *audioEngine) init() { + e.once.Do(func() { + data, err := bellFile.ReadFile("bells.wav") + if err != nil { + e.initErr = fmt.Errorf("read bell file: %w", err) + return + } + + pcm, sampleRate, channels, format, err := parseWAV(data) + if err != nil { + e.initErr = fmt.Errorf("parse WAV: %w", err) + return + } + + // oto's FormatUnsignedInt8 path has an integer underflow bug: it computes + // float32(v8-(1<<7)) with uint8 arithmetic, wrapping values below 128 into + // large positive numbers. Convert to signed 16-bit to use the correct path. + if format == oto.FormatUnsignedInt8 { + pcm16 := make([]byte, len(pcm)*2) + for i, b := range pcm { + s := (int16(b) - 128) * 256 + pcm16[2*i] = byte(s) + pcm16[2*i+1] = byte(s >> 8) + } + pcm = pcm16 + format = oto.FormatSignedInt16LE + } + + ctx, ready, err := oto.NewContext(&oto.NewContextOptions{ + SampleRate: sampleRate, + ChannelCount: channels, + Format: format, + BufferSize: time.Millisecond * 20, + }) + if err != nil { + e.initErr = fmt.Errorf("create audio context: %w", err) + return + } + <-ready + + e.ctx = ctx + e.pcm = pcm + }) +} + +// ring queues at most one pending bell, dropping extras while one is already queued. +func (e *audioEngine) ring() { + select { + case e.bellCh <- struct{}{}: + default: + } +} + +// run is the single bell-playing goroutine. It must be started once before ring is called. +func (e *audioEngine) run() { + e.init() + if e.initErr != nil { + log.Printf("audio init failed (%v); falling back to terminal bell", e.initErr) + } + for range e.bellCh { + if e.initErr != nil { + os.Stdout.Write([]byte{'\x07'}) + continue + } + player := e.ctx.NewPlayer(bytes.NewReader(e.pcm)) + player.Play() + for player.IsPlaying() { + time.Sleep(time.Millisecond) + } + } +} + +// --- WAV parser --- + +func parseWAV(data []byte) (pcm []byte, sampleRate, channels int, format oto.Format, err error) { + r := bytes.NewReader(data) + + var id [4]byte + if err = binary.Read(r, binary.LittleEndian, &id); err != nil { + return nil, 0, 0, 0, fmt.Errorf("read RIFF id: %w", err) + } + if string(id[:]) != "RIFF" { + return nil, 0, 0, 0, fmt.Errorf("not a RIFF file") + } + var fileSize uint32 + if err = binary.Read(r, binary.LittleEndian, &fileSize); err != nil { + return nil, 0, 0, 0, fmt.Errorf("read file size: %w", err) + } + if err = binary.Read(r, binary.LittleEndian, &id); err != nil { + return nil, 0, 0, 0, fmt.Errorf("read WAVE id: %w", err) + } + if string(id[:]) != "WAVE" { + return nil, 0, 0, 0, fmt.Errorf("not a WAVE file") + } + + var hdr struct { + AudioFormat uint16 + NumChannels uint16 + SampleRate uint32 + ByteRate uint32 + BlockAlign uint16 + BitsPerSample uint16 + } + var fmtFound bool + + for { + var chunkID [4]byte + if err = binary.Read(r, binary.LittleEndian, &chunkID); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + break + } + return nil, 0, 0, 0, fmt.Errorf("read chunk id: %w", err) + } + var chunkSize uint32 + if err = binary.Read(r, binary.LittleEndian, &chunkSize); err != nil { + return nil, 0, 0, 0, fmt.Errorf("read chunk size: %w", err) + } + + switch string(chunkID[:]) { + case "fmt ": + if chunkSize < 16 { + return nil, 0, 0, 0, fmt.Errorf("fmt chunk too small (%d bytes)", chunkSize) + } + if err = binary.Read(r, binary.LittleEndian, &hdr); err != nil { + return nil, 0, 0, 0, fmt.Errorf("read fmt chunk: %w", err) + } + if chunkSize > 16 { + skip := int64(chunkSize - 16) + if chunkSize%2 != 0 { + skip++ + } + if _, err = r.Seek(skip, io.SeekCurrent); err != nil { + return nil, 0, 0, 0, fmt.Errorf("skip fmt extra: %w", err) + } + } + fmtFound = true + case "data": + if !fmtFound { + return nil, 0, 0, 0, fmt.Errorf("data chunk before fmt chunk") + } + sz := chunkSize + if sz > uint32(r.Len()) { + sz = uint32(r.Len()) + } + pcm = make([]byte, sz) + if _, err = io.ReadFull(r, pcm); err != nil { + return nil, 0, 0, 0, fmt.Errorf("read data chunk: %w", err) + } + if chunkSize%2 != 0 { + r.Seek(1, io.SeekCurrent) + } + default: + skip := int64(chunkSize) + if chunkSize%2 != 0 { + skip++ + } + if _, err = r.Seek(skip, io.SeekCurrent); err != nil { + return nil, 0, 0, 0, fmt.Errorf("skip chunk: %w", err) + } + } + } + + if !fmtFound { + return nil, 0, 0, 0, fmt.Errorf("no fmt chunk in WAV file") + } + if pcm == nil { + return nil, 0, 0, 0, fmt.Errorf("no data chunk in WAV file") + } + + switch { + case hdr.AudioFormat == 1 && hdr.BitsPerSample == 8: + format = oto.FormatUnsignedInt8 + case hdr.AudioFormat == 1 && hdr.BitsPerSample == 16: + format = oto.FormatSignedInt16LE + case hdr.AudioFormat == 3 && hdr.BitsPerSample == 32: + format = oto.FormatFloat32LE + default: + return nil, 0, 0, 0, fmt.Errorf("unsupported WAV format: audio_format=%d, bits=%d", hdr.AudioFormat, hdr.BitsPerSample) + } + + return pcm, int(hdr.SampleRate), int(hdr.NumChannels), format, nil +} + +// --- PTY filter loop --- + +// filterLoop copies src to dst, stripping standalone BEL (0x07) bytes and calling bell() +// for each one. BEL bytes that appear as OSC string terminators are passed through unchanged +// so that the outer terminal does not get stuck mid-sequence. +// +// State machine covers the subset of ANSI/VT escape sequences that can contain 0x07: +// +// Normal → ESC → CSI (ESC [ ... final) +// ESC → OSC (ESC ] ... BEL or ST) +// ESC → Str (ESC P/X/^/_ ... ST) (DCS, SOS, PM, APC) +// ESC → +// +// ST = String Terminator = ESC \ +func filterLoop(dst io.Writer, src io.Reader, bell func()) error { + const ( + stNormal = iota + stEsc // saw 0x1b + stCSI // saw 0x1b [ — terminated by 0x40–0x7E + stOSC // saw 0x1b ] — terminated by BEL or ST + stStr // saw 0x1b P/X/^/_ — terminated by ST only + stST // saw 0x1b inside stOSC or stStr, checking for '\' (ST) + ) + + buf := make([]byte, 32*1024) + state := stNormal + parent := stNormal // parent of stST (stOSC or stStr) + esc := make([]byte, 0, 256) + + writeAll := func(p []byte) error { + _, err := dst.Write(p) + return err + } + flushEsc := func() error { + err := writeAll(esc) + esc = esc[:0] + return err + } + + for { + n, readErr := src.Read(buf) + p := buf[:n] + i := 0 + for i < len(p) { + b := p[i] + + // Fast path: bulk-copy normal bytes until a special byte is hit. + if state == stNormal { + j := i + for i < len(p) && p[i] != 0x07 && p[i] != 0x1b { + i++ + } + if i > j { + if err := writeAll(p[j:i]); err != nil { + return err + } + } + if i >= len(p) { + break + } + b = p[i] + } + i++ + + switch state { + case stNormal: + if b == 0x07 { + bell() + } else { // 0x1b + esc = append(esc[:0], b) + state = stEsc + } + + case stEsc: + switch b { + case '[': + esc = append(esc, b) + state = stCSI + case ']': + esc = append(esc, b) + state = stOSC + case 'P', 'X', '^', '_': // DCS, SOS, PM, APC + esc = append(esc, b) + state = stStr + case 0x1b: + // Another ESC: flush previous incomplete sequence, start fresh. + if err := writeAll(esc); err != nil { + return err + } + esc = esc[:0] + esc = append(esc, b) + // stay in stEsc + case 0x07: + // BEL right after bare ESC: flush ESC, ring bell. + if err := flushEsc(); err != nil { + return err + } + state = stNormal + bell() + default: + // Two-character escape sequence complete. + esc = append(esc, b) + if err := flushEsc(); err != nil { + return err + } + state = stNormal + } + + case stCSI: + if b == 0x07 { + // Unexpected BEL mid-CSI: flush incomplete sequence, ring bell. + if err := flushEsc(); err != nil { + return err + } + state = stNormal + bell() + } else { + esc = append(esc, b) + if b >= 0x40 && b <= 0x7e { // final byte + if err := flushEsc(); err != nil { + return err + } + state = stNormal + } + } + + case stOSC: + esc = append(esc, b) + if b == 0x07 { + // BEL terminates OSC — pass the whole sequence through unchanged. + if err := flushEsc(); err != nil { + return err + } + state = stNormal + } else if b == 0x1b { + parent = stOSC + state = stST + } + + case stStr: // DCS/SOS/PM/APC — only ST terminates + esc = append(esc, b) + if b == 0x1b { + parent = stStr + state = stST + } + + case stST: + esc = append(esc, b) + switch b { + case '\\': // String Terminator complete + if err := flushEsc(); err != nil { + return err + } + state = stNormal + case 0x07: + if parent == stOSC { + // BEL terminates OSC even after an intermediate ESC. + if err := flushEsc(); err != nil { + return err + } + state = stNormal + } else { + // Not a terminator for DCS/SOS/PM/APC; back to parent. + state = parent + } + case 0x1b: + // Another ESC inside string; stay in stST. + default: + // Not ST; back to parent string state. + state = parent + } + } + } + + if readErr != nil { + _ = flushEsc() // flush any buffered incomplete sequence + if isExpectedPTYErr(readErr) { + return nil + } + return readErr + } + } +} + +// isExpectedPTYErr returns true for errors that indicate normal PTY shutdown. +func isExpectedPTYErr(err error) bool { + if errors.Is(err, io.EOF) { + return true + } + var errno syscall.Errno + if errors.As(err, &errno) { + return errno == syscall.EIO + } + return false +} + +// --- Main --- + +func main() { + if len(os.Args) < 2 { + fmt.Fprintf(os.Stderr, "Usage: bellpilot [args...]\n") + os.Exit(1) + } + os.Exit(run(os.Args[1], os.Args[2:])) +} + +func run(name string, args []string) int { + go engine.run() + + c := exec.Command(name, args...) + + ptmx, err := pty.Start(c) + if err != nil { + log.Printf("start: %v", err) + return 1 + } + defer ptmx.Close() + + // Propagate terminal resize to PTY. + winchCh := make(chan os.Signal, 1) + signal.Notify(winchCh, syscall.SIGWINCH) + go func() { + for range winchCh { + if err := pty.InheritSize(os.Stdin, ptmx); err != nil { + log.Printf("resize: %v", err) + } + } + }() + winchCh <- syscall.SIGWINCH + + // Forward termination signals to the child. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP) + go func() { + for sig := range sigCh { + c.Process.Signal(sig) + } + }() + + // Put stdin in raw mode if it is a terminal. + if term.IsTerminal(int(os.Stdin.Fd())) { + oldState, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + log.Printf("make raw: %v", err) + return 1 + } + defer term.Restore(int(os.Stdin.Fd()), oldState) + } + + go io.Copy(ptmx, os.Stdin) + + if err := filterLoop(os.Stdout, ptmx, engine.ring); err != nil { + log.Printf("filter: %v", err) + } + + if err := c.Wait(); err != nil { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + log.Printf("wait: %v", err) + return 1 + } + return 0 +}