From 7ff7fdd0ccf1f8f5aed18e879cbc6614bc6096cb Mon Sep 17 00:00:00 2001 From: Sebastiaan de Schaetzen Date: Mon, 20 Apr 2026 13:56:00 +0200 Subject: [PATCH] Initial commit --- .gitignore | 9 + bells.wav | Bin 0 -> 18524 bytes go.mod | 13 ++ go.sum | 20 +++ main.go | 487 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 529 insertions(+) create mode 100644 .gitignore create mode 100644 bells.wav create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go 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 0000000000000000000000000000000000000000..9ad0fb23976544a8de054f93db03c3c42481bfce GIT binary patch literal 18524 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 Q3G(jqyZ-R&KWO~F0fV~&LI3~& literal 0 HcmV?d00001 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 +}