├── .gitignore ├── 3rdparty └── faiface │ └── beep │ └── wav │ ├── README.md │ ├── decode.go │ └── encode.go ├── LICENSE ├── README.md ├── app ├── change.go ├── chat.go ├── chatbox.go ├── error.go ├── filter.go ├── listening.go ├── ready.go ├── shutdown.go ├── talking.go └── thinking.go ├── docs ├── chatbox-tui.gif ├── chatbox.webp ├── i2cpinout.png └── pbpinout.png ├── go.mod ├── go.sum ├── hal ├── button_darwin.go ├── hal.go ├── hal_darwin.go ├── hal_linux.go ├── lcd_darwin.go ├── lcd_linux.go ├── leds_darwin.go ├── leds_linux.go └── ui │ └── model.go ├── lcd ├── README.md ├── RGB1602.py ├── lcd.go └── lcd.py ├── leds ├── audioSink.go └── visualizer.go ├── main.go ├── service └── addService.sh ├── strutil ├── strutil.go └── strutil_test.go └── tts ├── tts.go ├── tts_darwin.go └── tts_linux.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | chatbox 8 | 9 | # generated files 10 | *.wav 11 | *.pyc 12 | __pycache__ 13 | 14 | # Test binary, built with `go test -c` 15 | *.test 16 | 17 | # Output of the go coverage tool, specifically when used with LiteIDE 18 | *.out 19 | 20 | # Dependency directories (remove the comment below to include it) 21 | # vendor/ 22 | -------------------------------------------------------------------------------- /3rdparty/faiface/beep/wav/README.md: -------------------------------------------------------------------------------- 1 | These files come from faiface's beep repository. 2 | 3 | They were copied here so I could remove some of the debug printing which messes with the test UI. -------------------------------------------------------------------------------- /3rdparty/faiface/beep/wav/decode.go: -------------------------------------------------------------------------------- 1 | package wav 2 | 3 | import ( 4 | "encoding/binary" 5 | "encoding/hex" 6 | "fmt" 7 | "io" 8 | "time" 9 | 10 | "github.com/faiface/beep" 11 | "github.com/pkg/errors" 12 | ) 13 | 14 | // Decode takes a Reader containing audio data in WAVE format and returns a StreamSeekCloser, 15 | // which streams that audio. The Seek method will panic if rc is not io.Seeker. 16 | // 17 | // Do not close the supplied Reader, instead, use the Close method of the returned 18 | // StreamSeekCloser when you want to release the resources. 19 | func Decode(r io.Reader) (s beep.StreamSeekCloser, format beep.Format, err error) { 20 | d := decoder{r: r} 21 | defer func() { // hacky way to always close r if an error occurred 22 | if closer, ok := d.r.(io.Closer); ok { 23 | if err != nil { 24 | closer.Close() 25 | } 26 | } 27 | }() 28 | 29 | // READ "RIFF" header 30 | if err := binary.Read(r, binary.LittleEndian, d.h.RiffMark[:]); err != nil { 31 | return nil, beep.Format{}, errors.Wrap(err, "wav") 32 | } 33 | if string(d.h.RiffMark[:]) != "RIFF" { 34 | return nil, beep.Format{}, fmt.Errorf("wav: missing RIFF at the beginning > %s", string(d.h.RiffMark[:])) 35 | } 36 | 37 | // READ Total file size 38 | if err := binary.Read(r, binary.LittleEndian, &d.h.FileSize); err != nil { 39 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing RIFF file size") 40 | } 41 | if err := binary.Read(r, binary.LittleEndian, d.h.WaveMark[:]); err != nil { 42 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing RIFF file type") 43 | } 44 | if string(d.h.WaveMark[:]) != "WAVE" { 45 | return nil, beep.Format{}, errors.New("wav: unsupported file type") 46 | } 47 | 48 | // check each formtypes 49 | ft := [4]byte{0, 0, 0, 0} 50 | var fs int32 51 | d.hsz = 4 + 4 + 4 // add size of (RiffMark + FileSize + WaveMark) 52 | for string(ft[:]) != "data" { 53 | if err = binary.Read(r, binary.LittleEndian, ft[:]); err != nil { 54 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing chunk type") 55 | } 56 | switch { 57 | case string(ft[:]) == "fmt ": 58 | d.h.FmtMark = ft 59 | if err := binary.Read(r, binary.LittleEndian, &d.h.FormatSize); err != nil { 60 | return nil, beep.Format{}, errors.New("wav: missing format chunk size") 61 | } 62 | d.hsz += 4 + 4 + d.h.FormatSize // add size of (FmtMark + FormatSize + its trailing size) 63 | if err := binary.Read(r, binary.LittleEndian, &d.h.FormatType); err != nil { 64 | return nil, beep.Format{}, errors.New("wav: missing format type") 65 | } 66 | if d.h.FormatType == -2 { 67 | // WAVEFORMATEXTENSIBLE 68 | fmtchunk := formatchunkextensible{ 69 | formatchunk{0, 0, 0, 0, 0}, 0, 0, 0, 70 | guid{0, 0, 0, [8]byte{0, 0, 0, 0, 0, 0, 0, 0}}, 71 | } 72 | if err := binary.Read(r, binary.LittleEndian, &fmtchunk); err != nil { 73 | return nil, beep.Format{}, errors.New("wav: missing format chunk body") 74 | } 75 | d.h.NumChans = fmtchunk.NumChans 76 | d.h.SampleRate = fmtchunk.SampleRate 77 | d.h.ByteRate = fmtchunk.ByteRate 78 | d.h.BytesPerFrame = fmtchunk.BytesPerFrame 79 | d.h.BitsPerSample = fmtchunk.BitsPerSample 80 | 81 | // SubFormat is represented by GUID. Plain PCM is KSDATAFORMAT_SUBTYPE_PCM GUID. 82 | // See https://docs.microsoft.com/en-us/windows-hardware/drivers/ddi/content/ksmedia/ns-ksmedia-waveformatextensible 83 | pcmguid := guid{ 84 | 0x00000001, 0x0000, 0x0010, 85 | [8]byte{0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71}, 86 | } 87 | if fmtchunk.SubFormat != pcmguid { 88 | return nil, beep.Format{}, fmt.Errorf( 89 | "wav: unsupported sub format type - %08x-%04x-%04x-%s", 90 | fmtchunk.SubFormat.Data1, fmtchunk.SubFormat.Data2, fmtchunk.SubFormat.Data3, 91 | hex.EncodeToString(fmtchunk.SubFormat.Data4[:]), 92 | ) 93 | } 94 | } else { 95 | // WAVEFORMAT or WAVEFORMATEX 96 | fmtchunk := formatchunk{0, 0, 0, 0, 0} 97 | if err := binary.Read(r, binary.LittleEndian, &fmtchunk); err != nil { 98 | return nil, beep.Format{}, errors.New("wav: missing format chunk body") 99 | } 100 | d.h.NumChans = fmtchunk.NumChans 101 | d.h.SampleRate = fmtchunk.SampleRate 102 | d.h.ByteRate = fmtchunk.ByteRate 103 | d.h.BytesPerFrame = fmtchunk.BytesPerFrame 104 | d.h.BitsPerSample = fmtchunk.BitsPerSample 105 | 106 | // it would be skipping cbSize (WAVEFORMATEX's last member). 107 | if d.h.FormatSize > 16 { 108 | trash := make([]byte, d.h.FormatSize-16) 109 | if err := binary.Read(r, binary.LittleEndian, trash); err != nil { 110 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing extended format chunk body") 111 | } 112 | } 113 | } 114 | case string(ft[:]) == "data": 115 | d.h.DataMark = ft 116 | if err := binary.Read(r, binary.LittleEndian, &d.h.DataSize); err != nil { 117 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing data chunk size") 118 | } 119 | d.hsz += 4 + 4 //add size of (DataMark + DataSize) 120 | default: 121 | if err := binary.Read(r, binary.LittleEndian, &fs); err != nil { 122 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing unknown chunk size") 123 | } 124 | if fs%2 != 0 { 125 | fs = fs + 1 126 | } 127 | trash := make([]byte, fs) 128 | if err := binary.Read(r, binary.LittleEndian, trash); err != nil { 129 | return nil, beep.Format{}, errors.Wrap(err, "wav: missing unknown chunk body") 130 | } 131 | d.hsz += 4 + fs //add size of (Unknown formtype + formsize) 132 | } 133 | } 134 | 135 | if string(d.h.FmtMark[:]) != "fmt " { 136 | return nil, beep.Format{}, errors.New("wav: missing format chunk marker") 137 | } 138 | if string(d.h.DataMark[:]) != "data" { 139 | return nil, beep.Format{}, errors.New("wav: missing data chunk marker") 140 | } 141 | if d.h.FormatType != 1 && d.h.FormatType != -2 { 142 | return nil, beep.Format{}, fmt.Errorf("wav: unsupported format type - %d", d.h.FormatType) 143 | } 144 | if d.h.NumChans <= 0 { 145 | return nil, beep.Format{}, errors.New("wav: invalid number of channels (less than 1)") 146 | } 147 | if d.h.BitsPerSample != 8 && d.h.BitsPerSample != 16 && d.h.BitsPerSample != 24 { 148 | return nil, beep.Format{}, errors.New("wav: unsupported number of bits per sample, 8 or 16 or 24 are supported") 149 | } 150 | format = beep.Format{ 151 | SampleRate: beep.SampleRate(d.h.SampleRate), 152 | NumChannels: int(d.h.NumChans), 153 | Precision: int(d.h.BitsPerSample / 8), 154 | } 155 | return &d, format, nil 156 | } 157 | 158 | type guid struct { 159 | Data1 int32 160 | Data2 int16 161 | Data3 int16 162 | Data4 [8]byte 163 | } 164 | 165 | type formatchunk struct { 166 | NumChans int16 167 | SampleRate int32 168 | ByteRate int32 169 | BytesPerFrame int16 170 | BitsPerSample int16 171 | } 172 | 173 | type formatchunkextensible struct { 174 | formatchunk 175 | SubFormatSize int16 176 | Samples int16 // original: union 3 types of WORD member (wValidBisPerSample, wSamplesPerBlock, wReserved) 177 | ChannelMask int32 178 | SubFormat guid 179 | } 180 | 181 | type header struct { 182 | RiffMark [4]byte 183 | FileSize int32 184 | WaveMark [4]byte 185 | FmtMark [4]byte 186 | FormatSize int32 187 | FormatType int16 188 | NumChans int16 189 | SampleRate int32 190 | ByteRate int32 191 | BytesPerFrame int16 192 | BitsPerSample int16 193 | DataMark [4]byte 194 | DataSize int32 195 | } 196 | 197 | type decoder struct { 198 | r io.Reader 199 | h header 200 | hsz int32 201 | pos int32 202 | err error 203 | } 204 | 205 | func (d *decoder) Stream(samples [][2]float64) (n int, ok bool) { 206 | if d.err != nil || d.pos >= d.h.DataSize { 207 | return 0, false 208 | } 209 | bytesPerFrame := int(d.h.BytesPerFrame) 210 | numBytes := int32(len(samples) * bytesPerFrame) 211 | if numBytes > d.h.DataSize-d.pos { 212 | numBytes = d.h.DataSize - d.pos 213 | } 214 | p := make([]byte, numBytes) 215 | n, err := d.r.Read(p) 216 | if err != nil && err != io.EOF { 217 | d.err = err 218 | } 219 | switch { 220 | case d.h.BitsPerSample == 8 && d.h.NumChans == 1: 221 | for i, j := 0, 0; i <= n-bytesPerFrame; i, j = i+bytesPerFrame, j+1 { 222 | val := float64(p[i])/(1<<8-1)*2 - 1 223 | samples[j][0] = val 224 | samples[j][1] = val 225 | } 226 | case d.h.BitsPerSample == 8 && d.h.NumChans >= 2: 227 | for i, j := 0, 0; i <= n-bytesPerFrame; i, j = i+bytesPerFrame, j+1 { 228 | samples[j][0] = float64(p[i+0])/(1<<8-1)*2 - 1 229 | samples[j][1] = float64(p[i+1])/(1<<8-1)*2 - 1 230 | } 231 | case d.h.BitsPerSample == 16 && d.h.NumChans == 1: 232 | for i, j := 0, 0; i <= n-bytesPerFrame; i, j = i+bytesPerFrame, j+1 { 233 | val := float64(int16(p[i+0])+int16(p[i+1])*(1<<8)) / (1<<16 - 1) 234 | samples[j][0] = val 235 | samples[j][1] = val 236 | } 237 | case d.h.BitsPerSample == 16 && d.h.NumChans >= 2: 238 | for i, j := 0, 0; i <= n-bytesPerFrame; i, j = i+bytesPerFrame, j+1 { 239 | samples[j][0] = float64(int16(p[i+0])+int16(p[i+1])*(1<<8)) / (1<<16 - 1) 240 | samples[j][1] = float64(int16(p[i+2])+int16(p[i+3])*(1<<8)) / (1<<16 - 1) 241 | } 242 | case d.h.BitsPerSample == 24 && d.h.NumChans == 1: 243 | for i, j := 0, 0; i <= n-bytesPerFrame; i, j = i+bytesPerFrame, j+1 { 244 | val := float64((int32(p[i+0])<<8)+(int32(p[i+1])<<16)+(int32(p[i+2])<<24)) / (1 << 8) / (1<<24 - 1) 245 | samples[j][0] = val 246 | samples[j][1] = val 247 | } 248 | case d.h.BitsPerSample == 24 && d.h.NumChans >= 2: 249 | for i, j := 0, 0; i <= n-bytesPerFrame; i, j = i+bytesPerFrame, j+1 { 250 | samples[j][0] = float64((int32(p[i+0])<<8)+(int32(p[i+1])<<16)+(int32(p[i+2])<<24)) / (1 << 8) / (1<<24 - 1) 251 | samples[j][1] = float64((int32(p[i+3])<<8)+(int32(p[i+4])<<16)+(int32(p[i+5])<<24)) / (1 << 8) / (1<<24 - 1) 252 | } 253 | } 254 | d.pos += int32(n) 255 | return n / bytesPerFrame, true 256 | } 257 | 258 | func (d *decoder) Err() error { 259 | return d.err 260 | } 261 | 262 | func (d *decoder) Len() int { 263 | numBytes := time.Duration(d.h.DataSize) 264 | perFrame := time.Duration(d.h.BytesPerFrame) 265 | return int(numBytes / perFrame) 266 | } 267 | 268 | func (d *decoder) Position() int { 269 | return int(d.pos / int32(d.h.BytesPerFrame)) 270 | } 271 | 272 | func (d *decoder) Seek(p int) error { 273 | seeker, ok := d.r.(io.Seeker) 274 | if !ok { 275 | return fmt.Errorf("wav: seek: resource is not io.Seeker") 276 | } 277 | if p < 0 || d.Len() < p { 278 | return fmt.Errorf("wav: seek position %v out of range [%v, %v]", p, 0, d.Len()) 279 | } 280 | pos := int32(p) * int32(d.h.BytesPerFrame) 281 | _, err := seeker.Seek(int64(pos+d.hsz), io.SeekStart) // hsz is the size of the header 282 | if err != nil { 283 | return errors.Wrap(err, "wav: seek error") 284 | } 285 | d.pos = pos 286 | return nil 287 | } 288 | 289 | func (d *decoder) Close() error { 290 | if closer, ok := d.r.(io.Closer); ok { 291 | err := closer.Close() 292 | if err != nil { 293 | return errors.Wrap(err, "wav") 294 | } 295 | } 296 | return nil 297 | } 298 | -------------------------------------------------------------------------------- /3rdparty/faiface/beep/wav/encode.go: -------------------------------------------------------------------------------- 1 | package wav 2 | 3 | import ( 4 | "bufio" 5 | "encoding/binary" 6 | "fmt" 7 | "io" 8 | 9 | "github.com/faiface/beep" 10 | "github.com/pkg/errors" 11 | ) 12 | 13 | // Encode writes all audio streamed from s to w in WAVE format. 14 | // 15 | // Format precision must be 1 or 2 bytes. 16 | func Encode(w io.WriteSeeker, s beep.Streamer, format beep.Format) (err error) { 17 | defer func() { 18 | if err != nil { 19 | err = errors.Wrap(err, "wav") 20 | } 21 | }() 22 | 23 | if format.NumChannels <= 0 { 24 | return errors.New("wav: invalid number of channels (less than 1)") 25 | } 26 | if format.Precision != 1 && format.Precision != 2 && format.Precision != 3 { 27 | return errors.New("wav: unsupported precision, 1, 2 or 3 is supported") 28 | } 29 | 30 | h := header{ 31 | RiffMark: [4]byte{'R', 'I', 'F', 'F'}, 32 | FileSize: -1, // finalization 33 | WaveMark: [4]byte{'W', 'A', 'V', 'E'}, 34 | FmtMark: [4]byte{'f', 'm', 't', ' '}, 35 | FormatSize: 16, 36 | FormatType: 1, 37 | NumChans: int16(format.NumChannels), 38 | SampleRate: int32(format.SampleRate), 39 | ByteRate: int32(int(format.SampleRate) * format.NumChannels * format.Precision), 40 | BytesPerFrame: int16(format.NumChannels * format.Precision), 41 | BitsPerSample: int16(format.Precision) * 8, 42 | DataMark: [4]byte{'d', 'a', 't', 'a'}, 43 | DataSize: -1, // finalization 44 | } 45 | if err := binary.Write(w, binary.LittleEndian, &h); err != nil { 46 | return err 47 | } 48 | 49 | var ( 50 | bw = bufio.NewWriter(w) 51 | samples = make([][2]float64, 512) 52 | buffer = make([]byte, len(samples)*format.Width()) 53 | written int 54 | ) 55 | for { 56 | n, ok := s.Stream(samples) 57 | if !ok { 58 | break 59 | } 60 | buf := buffer 61 | switch { 62 | case format.Precision == 1: 63 | for _, sample := range samples[:n] { 64 | buf = buf[format.EncodeUnsigned(buf, sample):] 65 | } 66 | case format.Precision == 2 || format.Precision == 3: 67 | for _, sample := range samples[:n] { 68 | buf = buf[format.EncodeSigned(buf, sample):] 69 | } 70 | default: 71 | return fmt.Errorf("wav: encode: invalid precision: %d", format.Precision) 72 | } 73 | nn, err := bw.Write(buffer[:n*format.Width()]) 74 | if err != nil { 75 | return err 76 | } 77 | written += nn 78 | } 79 | if err := bw.Flush(); err != nil { 80 | return err 81 | } 82 | 83 | // finalize header 84 | h.FileSize = int32(44 + written) // 44 is the size of the header 85 | h.DataSize = int32(written) 86 | if _, err := w.Seek(0, io.SeekStart); err != nil { 87 | return err 88 | } 89 | if err := binary.Write(w, binary.LittleEndian, &h); err != nil { 90 | return err 91 | } 92 | if _, err := w.Seek(0, io.SeekEnd); err != nil { 93 | return err 94 | } 95 | 96 | return nil 97 | } 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Hoani Bryson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A chatbot companion project. 2 | 3 | 4 | 5 | Combines openai's chat and voice apis with hardware to make an AI you can talk to. 6 | 7 | ## Running 8 | 9 | This project was developped on Macbook and a Raspberry Pi. 10 | 11 | Before running this make sure you set your `OPENAI_KEY` environment variable: 12 | 13 | ``` 14 | export OPENAI_KEY= 15 | ``` 16 | 17 | ### Macbook Development 18 | 19 | On a Macbook, there is a TUI (terminal UI) which simulates some of the hardware such as LEDs, LCD and Pushbutton. It will use your computers microphone and speaker hardware, just like the raspberry pi does. 20 | 21 | 22 | 23 | 24 | ### Raspberry Pi Development 25 | 26 | To develop on a raspberry pi, I suggest running this with: 27 | 28 | ``` 29 | go run . 2> /dev/null 30 | ``` 31 | 32 | Otherwise you may see a lot of ALSA logs complaining about the hardware... I tried to remove these in a sensible way, but it appears to be a known issue with portaudio and ALSA's libraries. 33 | 34 | ### Raspberry Pi Deployment 35 | 36 | To deploy this on your Raspberry Pi, run: 37 | 38 | ``` 39 | go build . 40 | ./service/addService.sh 41 | systemctl --user start chatbox 42 | ``` 43 | 44 | To make it run on boot: 45 | ``` 46 | systemctl --user enable chatbox 47 | ``` 48 | 49 | To check logs: 50 | ``` 51 | journalctl --user -u chatbox --since "10 minutes ago" 52 | ``` 53 | 54 | The hardware I run this on is a Rasberry Pi 4B 2GB... hopefully there are no memory leaks. 55 | 56 | Running this on a Raspberry Pi with Desktop is fine, but I suggest switching to terminal mode once you have put your Pi in your enclosure. 57 | 58 | ## Hardware Setup 59 | 60 | This project expects that the raspberry pi is connected to an arduino running serial pixel: https://github.com/hoani/serial-pixel 61 | 62 | Make sure you enable I2C in the raspberry pi - this is needed for the LCD. 63 | 64 | ### Raspberry Pi Dependencies 65 | 66 | The basic audio libraries used are: 67 | 68 | ``` 69 | apt install mpg123 portaudio19-dev espeak 70 | ``` 71 | 72 | In addition, I use `mbrola` voices, which sound a little better than espeak's defaults: 73 | 74 | ``` 75 | apt install mbrola mbrola-en1 mbrola-us1 76 | ``` 77 | 78 | ### Raspberry Pi Connections 79 | 80 | The LCD used for this project is [LCD1602](https://www.waveshare.com/wiki/LCD1602_RGB_Module). 81 | 82 | This is connected to the i2c pins 3 and 5, as well as GND and 3V3. 83 | 84 | 85 | 86 | A push button is used for signalling when it is time to talk to the chatbox. This is connected to GPIO 5 and GND: 87 | 88 | 89 | 90 | In my build, I: 91 | * connected my serial-pixel arduino into a USB port 92 | * used a USB speaker which connected to the Pi with an audio jack 93 | * used a USB webcam as my microphone 94 | 95 | ## Custom commands 96 | 97 | I got sick of rebooting my raspberry pi. So I have added a couple of control commands that you can speak to the chatbox: 98 | 99 | * `"shutdown"` will shutdown the raspberry pi... very helpful in avoiding corrupting your disk drives. 100 | * `"change personality to "` will change the personality of the chatbox and reset your chat history 101 | 102 | # Hardware 103 | 104 | ## Bill of materials 105 | 106 | This list isn't exhaustive, a lot of the decisions I made were based around what is available to buy locally in New Zealand. 107 | 108 | * Raspberry Pi 4B/3B+ 109 | * any RAM is fine, this project uses only ~200Mb max 110 | * Raspberry Pi 3B+ 111 | * Teensy 4.0 112 | * Pretty much any other arduino that fits in the box also works 113 | * Genius SP-HF280 USB Speakers 114 | * Duinotech 24 LED 72mm circular board 115 | * [Sunfounder USB Microphone](https://www.sunfounder.com/products/mini-usb-microphone) 116 | * [Waveshare RGB 1602 LCD](https://www.waveshare.com/lcd1602-rgb-module.htm) 117 | * Push button 118 | 119 | There were also a bunch of 3D printed parts and so on. A video showing the build is on youtube: 120 | [![Chatbox: Leetware](https://img.youtube.com/vi/rzS5zLpd1os/0.jpg)](https://www.youtube.com/watch?v=rzS5zLpd1os) 121 | -------------------------------------------------------------------------------- /app/change.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "sync" 8 | "time" 9 | 10 | "github.com/hoani/chatbox/hal" 11 | "github.com/hoani/chatbox/lcd" 12 | openai "github.com/sashabaranov/go-openai" 13 | ) 14 | 15 | func (c *chatbox) doStateChange() state { 16 | cleanup := c.runTalkingVisualizer(hal.HSV{ 17 | H: 0xd0, 18 | S: 0x80, 19 | V: 0x50, 20 | }) 21 | defer cleanup() 22 | 23 | c.hal.LCD().Write(lcd.Pad("[Change]"), "", hal.LCDBlue) 24 | 25 | wg := sync.WaitGroup{} 26 | wg.Add(1) 27 | go func() { 28 | defer wg.Done() 29 | c.getGender() 30 | }() 31 | 32 | c.processDirective(fmt.Sprintf("(changing personality to %s)", c.personality)) 33 | c.chat = c.newCustomChatRequest(c.personality) 34 | 35 | wg.Wait() 36 | 37 | return stateReady 38 | } 39 | 40 | func (c *chatbox) getGender() { 41 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 42 | defer cancel() 43 | 44 | query := fmt.Sprintf("Respond with only 'Male' or 'Female'. What gender is %s?", c.personality) 45 | res, err := c.openai.CreateChatCompletion( 46 | ctx, 47 | openai.ChatCompletionRequest{ 48 | Model: openai.GPT3Dot5Turbo, 49 | Messages: []openai.ChatCompletionMessage{ 50 | { 51 | Role: openai.ChatMessageRoleUser, 52 | Content: query, 53 | }, 54 | }, 55 | Temperature: 0.2, 56 | }, 57 | ) 58 | if err != nil || len(res.Choices) == 0 { 59 | return 60 | } 61 | c.ttsCfg.Male = !strings.Contains(strings.ToUpper(res.Choices[0].Message.Content), "FEMALE") 62 | } 63 | -------------------------------------------------------------------------------- /app/chat.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | openai "github.com/sashabaranov/go-openai" 8 | ) 9 | 10 | const systemMsgBase = "Respond as an exaggerated %s whose soul is trapped inside a raspberry Pi. " + 11 | "When possible keep responses to less than three sentences. " 12 | 13 | func (c *chatbox) newChatRequest() *openai.ChatCompletionRequest { 14 | return c.newCustomChatRequest("Jim Carrey") 15 | } 16 | 17 | func (c *chatbox) systemMessage() string { 18 | return fmt.Sprintf(systemMsgBase, c.personality) 19 | } 20 | 21 | func (c *chatbox) newCustomChatRequest(personality string) *openai.ChatCompletionRequest { 22 | c.personality = personality 23 | return &openai.ChatCompletionRequest{ 24 | Model: openai.GPT3Dot5Turbo, 25 | Messages: []openai.ChatCompletionMessage{ 26 | { 27 | Role: openai.ChatMessageRoleSystem, 28 | Content: c.systemMessage(), 29 | }, 30 | }, 31 | Temperature: 1.0, 32 | } 33 | } 34 | 35 | func (c *chatbox) refreshChatRequest() { 36 | c.chat.Messages[0].Content = c.systemMessage() 37 | if time.Since(c.lastChat) < time.Hour*24*365 { 38 | c.chat.Messages[0].Content += "the last time we spoke was " + c.lastChat.Format(time.Stamp) + "." 39 | } 40 | c.chat.Messages[0].Content += " the current time is " + time.Now().Format(time.Stamp) + "." 41 | } 42 | -------------------------------------------------------------------------------- /app/chatbox.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | "github.com/hoani/chatbox/hal" 9 | "github.com/hoani/chatbox/tts" 10 | openai "github.com/sashabaranov/go-openai" 11 | ) 12 | 13 | type state int 14 | 15 | const buttonDebounce = time.Millisecond * 50 16 | 17 | const ( 18 | stateReady state = iota 19 | stateListening 20 | stateThinking 21 | stateTalking 22 | stateShutdown 23 | stateChange 24 | stateError 25 | ) 26 | 27 | type chatbox struct { 28 | openai *openai.Client 29 | hal hal.Hal 30 | wd string 31 | state state 32 | recordingCh chan string 33 | chat *openai.ChatCompletionRequest 34 | errorMessage string 35 | lastChat time.Time 36 | personality string 37 | moderator *moderator 38 | ttsCfg tts.Config 39 | } 40 | 41 | func NewChatBox(key string) (*chatbox, error) { 42 | if key == "" { 43 | return nil, errors.New("missing openai key") 44 | } 45 | c := openai.NewClient(key) 46 | 47 | h, err := hal.NewHal() 48 | if err != nil { 49 | return nil, err 50 | } 51 | 52 | wd, err := os.Getwd() 53 | if err != nil { 54 | return nil, err 55 | } 56 | 57 | hsvs := []hal.HSV{} 58 | for i := 0; i < 24; i++ { 59 | hsvs = append(hsvs, hal.HSV{ 60 | H: uint8(i) * 10, 61 | S: 0xFF, 62 | V: 0x50, 63 | }) 64 | } 65 | 66 | h.Leds().HSV(0, hsvs...) 67 | h.Leds().Show() 68 | 69 | h.LCD().Write("Hello Chatbot", "Press to start", hal.LCDBlue) 70 | 71 | return &chatbox{ 72 | openai: c, 73 | hal: h, 74 | wd: wd, 75 | state: stateReady, 76 | recordingCh: make(chan string), 77 | moderator: newModerator(c), 78 | }, nil 79 | } 80 | 81 | func (c *chatbox) Run() error { 82 | c.chat = c.newChatRequest() 83 | c.getGender() 84 | 85 | for { 86 | c.state = c.doState() 87 | } 88 | } 89 | 90 | func (c *chatbox) doState() state { 91 | switch c.state { 92 | case stateReady: 93 | return c.doStateReady() 94 | case stateListening: 95 | return c.doStateListening() 96 | case stateThinking: 97 | return c.doStateThinking() 98 | case stateTalking: 99 | return c.doStateTalking() 100 | case stateError: 101 | return c.doStateError() 102 | case stateShutdown: 103 | return c.doStateShutdown() 104 | case stateChange: 105 | return c.doStateChange() 106 | } 107 | return stateReady 108 | } 109 | -------------------------------------------------------------------------------- /app/error.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "math" 6 | "strings" 7 | "time" 8 | 9 | "github.com/hoani/chatbox/hal" 10 | "github.com/hoani/chatbox/lcd" 11 | ) 12 | 13 | const errorLCDUpdateRate = time.Millisecond * 100 14 | 15 | func (c *chatbox) doStateError() state { 16 | ctx, cancel := context.WithCancel(context.Background()) 17 | defer cancel() 18 | go func() { 19 | hsvs := []hal.HSV{} 20 | for i := 0; i < 24; i++ { 21 | hsvs = append(hsvs, hal.HSV{ 22 | H: 0x00, 23 | S: 0xFF, 24 | V: 20 + 20*uint8(i), 25 | }) 26 | } 27 | start := time.Now() 28 | for { 29 | select { 30 | case <-ctx.Done(): 31 | return 32 | case <-time.After(time.Millisecond * 50): 33 | v := 0x80 + uint8(0x60*math.Sin(math.Pi*float64(time.Since(start).Seconds()/10))) 34 | for i := 0; i < 24; i++ { 35 | hsvs[i].H = uint8(time.Since(start).Seconds()) 36 | hsvs[i].V = v 37 | } 38 | 39 | c.hal.Leds().HSV(0, hsvs...) 40 | c.hal.Leds().Show() 41 | } 42 | } 43 | }() 44 | 45 | msg := strings.Repeat(" ", 16) + c.errorMessage + strings.Repeat(" ", 16) 46 | index := 0 47 | lcdLast := time.Now() 48 | start := time.Now() 49 | 50 | for { 51 | if time.Since(lcdLast) >= errorLCDUpdateRate { 52 | lcdLast = lcdLast.Add(errorLCDUpdateRate) 53 | c.hal.LCD().Write(lcd.Pad("[error]"), msg[index:index+15], hal.LCDRed) 54 | index = (index + 1) % (len(msg) - 16) 55 | } 56 | 57 | if c.hal.Button() && time.Since(start) > time.Second { 58 | break 59 | } 60 | time.Sleep(time.Millisecond * 10) 61 | 62 | } 63 | return stateReady 64 | } 65 | -------------------------------------------------------------------------------- /app/filter.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/sashabaranov/go-openai" 8 | ) 9 | 10 | type moderator struct { 11 | c *openai.Client 12 | } 13 | 14 | const moderationTimeout = time.Second * 2 15 | 16 | func newModerator(c *openai.Client) *moderator { 17 | return &moderator{c: c} 18 | } 19 | 20 | func (m *moderator) Moderate(input string) bool { 21 | ctx, cancel := context.WithTimeout(context.Background(), moderationTimeout) 22 | defer cancel() 23 | resp, err := m.c.Moderations( 24 | ctx, 25 | openai.ModerationRequest{Input: input}, 26 | ) 27 | if err != nil { 28 | // If we can't check the request, assume it is OK. 29 | return true 30 | } 31 | 32 | for _, result := range resp.Results { 33 | if result.Flagged { 34 | // Always reject on these ones. 35 | if result.Categories.HateThreatening || 36 | result.Categories.SelfHarm || 37 | result.Categories.Sexual || 38 | result.Categories.SexualMinors { 39 | return false 40 | } 41 | // Sometimes you might have a creative conversation, like "an alien threatens to shoot everyone". 42 | // Ideally we don't flag it, because chat handles this pretty well anyway. 43 | cumulativeScore := result.CategoryScores.Hate + result.CategoryScores.HateThreatening + 44 | result.CategoryScores.Violence + result.CategoryScores.ViolenceGraphic 45 | if cumulativeScore < 1.0 { 46 | return true 47 | } else { 48 | return false 49 | } 50 | } 51 | } 52 | return true 53 | } 54 | -------------------------------------------------------------------------------- /app/listening.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/faiface/beep" 11 | "github.com/hoani/chatbox/3rdparty/faiface/beep/wav" 12 | "github.com/hoani/chatbox/hal" 13 | "github.com/hoani/chatbox/leds" 14 | "github.com/hoani/toot" 15 | ) 16 | 17 | func (c *chatbox) doStateListening() state { 18 | start := time.Now() 19 | 20 | ctx, cancel := context.WithCancel(context.Background()) 21 | defer cancel() 22 | 23 | path := filepath.Join(c.wd, "test.wav") 24 | f, err := os.Create(path) 25 | if err != nil { 26 | c.errorMessage = "unable to record to file" 27 | c.hal.Debug(err.Error()) 28 | return stateError 29 | } 30 | m, err := toot.NewDefaultMicrophone() 31 | if err != nil { 32 | c.errorMessage = "unable to open mic" 33 | c.hal.Debug(err.Error()) 34 | return stateError 35 | } 36 | defer func() { 37 | go func() { 38 | time.Sleep(time.Second) // Delay a bit before stopping. 39 | m.Close() 40 | }() 41 | }() 42 | // Reduced sample rate to still capture voice, but reduce latency on RPi 3B+. 43 | // ChatGPT told me that human voice ranges from 80 - 2500Hz, so we want a sample rate of at least 5000Hz. 44 | // Assuming our microphone is At least 40Khz, we should be ok here. 45 | m.SetSampleRate(m.Format().SampleRate / 4) 46 | 47 | v := leds.NewVisualizer( 48 | leds.WithSource(&leds.Source{ 49 | Streamer: m, 50 | SampleRate: m.Format().SampleRate, 51 | }), 52 | leds.WithSink(func(s beep.Streamer) { 53 | go func() { 54 | err = wav.Encode(f, s, m.Format()) 55 | if err != nil { 56 | c.hal.Debug(fmt.Sprintf("error encoding wav: %v", err)) 57 | path = "" 58 | } 59 | f.Close() 60 | c.hal.Debug(path) 61 | time.Sleep(10 * time.Millisecond) 62 | c.recordingCh <- path 63 | }() 64 | }), 65 | ) 66 | go v.Start(ctx) 67 | 68 | // c.hal.Debug(fmt.Sprintf("%#v\n", m.DeviceInfo())) 69 | 70 | if err := m.Start(ctx); err != nil { 71 | c.errorMessage = "unable to start mic" 72 | c.hal.Debug(err.Error()) 73 | return stateError 74 | } 75 | 76 | c.hal.LCD().Write(" [Listening] ", "release to stop", hal.LCDGreen) 77 | 78 | hsvs := []hal.HSV{} 79 | for i := 0; i < 24; i++ { 80 | hsvs = append(hsvs, hal.HSV{ 81 | H: 0x60, 82 | S: 0xFF, 83 | V: 0x50, 84 | }) 85 | } 86 | 87 | voicePowerEstimate := 0.0 88 | 89 | for { 90 | if time.Since(start) > 2*time.Minute { 91 | m.Close() 92 | c.errorMessage = "recording is too long" 93 | return stateError 94 | } 95 | if !c.hal.Button() { 96 | if time.Since(start) < buttonDebounce { 97 | continue // Allow for debounce. 98 | } 99 | if time.Since(start) < time.Second { 100 | m.Close() 101 | c.errorMessage = "recording is too short" 102 | return stateError 103 | } 104 | averagePowerEstimate := voicePowerEstimate / time.Since(start).Seconds() 105 | if averagePowerEstimate < 10.0 { 106 | m.Close() 107 | c.errorMessage = fmt.Sprintf("recording is too quiet %.2f", averagePowerEstimate) 108 | return stateError 109 | } 110 | break 111 | } 112 | 113 | time.Sleep(time.Millisecond * 20) 114 | 115 | channels := v.Channels() 116 | 117 | N := len(channels) / 4 118 | for i := 0; i < N; i++ { 119 | voicePowerEstimate += 0.02 * channels[i] / float64(N) 120 | } 121 | 122 | for i := range hsvs { 123 | j := i 124 | if j >= leds.NChannels { 125 | j = leds.NChannels - (1 + i - leds.NChannels) 126 | } 127 | v := channels[j] 128 | if v > float64(0xa0) { 129 | v = float64(0xa0) 130 | } 131 | hsvs[i].V = 0x40 + uint8(v) 132 | } 133 | 134 | c.hal.Leds().HSV(0, hsvs...) 135 | c.hal.Leds().Show() 136 | } 137 | 138 | return stateThinking 139 | } 140 | -------------------------------------------------------------------------------- /app/ready.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/hoani/chatbox/hal" 8 | "github.com/hoani/chatbox/lcd" 9 | "github.com/hoani/chatbox/leds" 10 | ) 11 | 12 | func (c *chatbox) doStateReady() state { 13 | c.readyLCD() 14 | 15 | // Wait for button release. 16 | for c.hal.Button() { 17 | time.Sleep(buttonDebounce) 18 | } 19 | time.Sleep(buttonDebounce) // We delay a little bit extra to allow for button debounce. 20 | 21 | ctx, cancel := context.WithCancel(context.Background()) 22 | defer cancel() 23 | 24 | hsvs := []hal.HSV{} 25 | for i := 0; i < 24; i++ { 26 | hsvs = append(hsvs, hal.HSV{ 27 | H: uint8(i) * 10, 28 | S: 0xFF, 29 | V: 0x50, 30 | }) 31 | } 32 | 33 | v := leds.NewVisualizer() 34 | go v.Start(ctx) 35 | defer v.Wait() 36 | 37 | for { 38 | if c.hal.Button() { 39 | cancel() 40 | break 41 | } 42 | 43 | time.Sleep(20 * time.Millisecond) 44 | 45 | c.readyLCD() 46 | c.readyLEDs(hsvs, v) 47 | c.restartOldChat() 48 | } 49 | return stateListening 50 | } 51 | 52 | func (c *chatbox) readyLCD() { 53 | timestamp := time.Now().Format(time.Kitchen) 54 | if len(timestamp) == len(time.Kitchen) { 55 | timestamp = " " + timestamp 56 | } 57 | c.hal.LCD().Write(lcd.Pad(timestamp), lcd.Pad("Press to start"), hal.LCDBlue) 58 | } 59 | 60 | func (c *chatbox) readyLEDs(hsvs []hal.HSV, v leds.Visualizer) { 61 | channels := v.Channels() 62 | 63 | for i := range hsvs { 64 | hsvs[i].H += 1 65 | j := i 66 | if j >= leds.NChannels { 67 | j = leds.NChannels - (1 + i - leds.NChannels) 68 | } 69 | v := channels[j] 70 | if v > float64(0xa0) { 71 | v = float64(0xa0) 72 | } 73 | hsvs[i].V = 0x40 + uint8(v) 74 | } 75 | 76 | c.hal.Leds().HSV(0, hsvs...) 77 | c.hal.Leds().Show() 78 | } 79 | 80 | // Restarts chat at midnight if no one is actively using the chatbox. 81 | func (c *chatbox) restartOldChat() { 82 | now := time.Now() 83 | if now.Hour() == 0 && now.Minute() == 0 && now.Second() == 0 { 84 | if time.Since(c.lastChat) > time.Hour { 85 | c.chat = c.newChatRequest() 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/shutdown.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "github.com/hoani/chatbox/hal" 5 | "github.com/hoani/chatbox/lcd" 6 | ) 7 | 8 | func (c *chatbox) doStateShutdown() state { 9 | cleanup := c.runTalkingVisualizer(hal.HSV{ 10 | H: 0xd0, 11 | S: 0x80, 12 | V: 0x50, 13 | }) 14 | defer cleanup() 15 | 16 | c.hal.LCD().Write(lcd.Pad("[Shutdown]"), "", hal.LCDBlue) 17 | 18 | c.speak("(shutting down)") 19 | 20 | c.hal.Shutdown() 21 | 22 | return stateReady 23 | } 24 | -------------------------------------------------------------------------------- /app/talking.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "sync" 7 | "time" 8 | 9 | "github.com/hoani/chatbox/hal" 10 | "github.com/hoani/chatbox/lcd" 11 | "github.com/hoani/chatbox/leds" 12 | "github.com/hoani/chatbox/strutil" 13 | "github.com/hoani/chatbox/tts" 14 | openai "github.com/sashabaranov/go-openai" 15 | ) 16 | 17 | func (c *chatbox) doStateTalking() state { 18 | message := c.chat.Messages[len(c.chat.Messages)-1] 19 | if message.Role != openai.ChatMessageRoleAssistant { 20 | return stateReady // Oops, this isn't a response, best get out of here. 21 | } 22 | content := message.Content 23 | 24 | cleanup := c.runTalkingVisualizer(hal.HSV{ 25 | H: 0x80, 26 | S: 0x00, 27 | V: 0x50, 28 | }) 29 | defer cleanup() 30 | 31 | c.hal.LCD().Write(lcd.Pad("[Talking]"), "", hal.LCDBlue) 32 | directives := strutil.SplitBrackets(content) 33 | for _, directive := range directives { 34 | directive = strutil.Simplify(directive) 35 | c.processDirective(directive) 36 | } 37 | 38 | return stateReady 39 | } 40 | 41 | func (c *chatbox) processDirective(d string) { 42 | if strings.HasPrefix(d, "[") { 43 | return 44 | } 45 | if strings.HasPrefix(d, "(") || strings.HasPrefix(d, "*") { 46 | c.ttsCfg.AltVoice = true 47 | } else { 48 | c.ttsCfg.AltVoice = false 49 | } 50 | c.processSpeech(d) 51 | } 52 | 53 | func (c *chatbox) processSpeech(in string) { 54 | sentences := strutil.SplitSentences(in) 55 | for _, sentence := range sentences { 56 | parts := strutil.SplitWidth(sentence, 16) 57 | wg := sync.WaitGroup{} 58 | wg.Add(1) 59 | go func() { 60 | defer wg.Done() 61 | c.speak(sentence) 62 | }() 63 | wpm := 155 64 | adjustment := 0.6 // espeak is a bit faster than the wpm would suggest. 65 | mspw := int(adjustment * (60.0 * 1000.0) / float64(wpm)) 66 | for _, part := range parts { 67 | part = strings.TrimSpace(part) 68 | words := strings.Count(part, " ") + 1 69 | padding := (16 - len(part)) / 2 70 | part = strings.Repeat(" ", padding) + part 71 | c.hal.LCD().Write(lcd.Pad("[Talking]"), part, hal.LCDAqua) 72 | time.Sleep(time.Millisecond * time.Duration(mspw*words)) 73 | } 74 | wg.Wait() 75 | } 76 | } 77 | 78 | func (c *chatbox) speak(sentence string) { 79 | tts.Speak(sentence, c.ttsCfg) 80 | } 81 | 82 | func (c *chatbox) runTalkingVisualizer(baseHsv hal.HSV) (cleanup func()) { 83 | // Ideally, we would use the audio out rather than microphone... but this works well anyway. 84 | v := leds.NewVisualizer() 85 | 86 | ctx, cancel := context.WithCancel(context.Background()) 87 | go v.Start(ctx) 88 | 89 | go func() { 90 | hsvs := make([]hal.HSV, 24) 91 | 92 | for { 93 | select { 94 | case <-ctx.Done(): 95 | return 96 | case <-time.After(time.Millisecond * 50): 97 | channels := v.Channels() 98 | for i := range hsvs { 99 | hsvs[i] = baseHsv 100 | hsvs[i].V = 0x50 + uint8(channels[i%leds.NChannels]) 101 | } 102 | 103 | c.hal.Leds().HSV(0, hsvs...) 104 | c.hal.Leds().Show() 105 | } 106 | } 107 | }() 108 | 109 | return func() { 110 | cancel() 111 | v.Wait() 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /app/thinking.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/hoani/chatbox/hal" 10 | "github.com/hoani/chatbox/lcd" 11 | openai "github.com/sashabaranov/go-openai" 12 | ) 13 | 14 | func (c *chatbox) doStateThinking() state { 15 | c.hal.LCD().Write(lcd.Pad("[Thinking]"), "", hal.LCDBlue) 16 | 17 | ctx, cancel := context.WithCancel(context.Background()) 18 | defer cancel() 19 | 20 | go func() { 21 | hsvs := []hal.HSV{} 22 | for i := 0; i < 12; i++ { 23 | hsvs = append(hsvs, hal.HSV{ 24 | H: 0xa0, 25 | S: 0xFF, 26 | V: 20 + 20*uint8(i), 27 | }) 28 | } 29 | for i := 0; i < 12; i++ { 30 | hsvs = append(hsvs, hal.HSV{ 31 | H: 0xf0, 32 | S: 0xFF, 33 | V: 20 + 20*uint8(i), 34 | }) 35 | } 36 | 37 | for { 38 | select { 39 | case <-ctx.Done(): 40 | return 41 | case <-time.After(time.Millisecond * 50): 42 | last := hsvs[23] 43 | for i := 23; i >= 0; i-- { 44 | hsvs[i] = hsvs[(i+23)%24] 45 | } 46 | hsvs[0] = last 47 | 48 | c.hal.Leds().HSV(0, hsvs...) 49 | c.hal.Leds().Show() 50 | } 51 | } 52 | }() 53 | 54 | var path string 55 | select { 56 | case path = <-c.recordingCh: 57 | if path == "" { 58 | c.hal.Debug("recording path is empty") 59 | return stateReady 60 | } 61 | case <-time.After(time.Second * 5): 62 | c.hal.Debug("timeout waiting for recording") 63 | return stateReady 64 | } 65 | 66 | transcription, err := c.openai.CreateTranscription( 67 | ctx, 68 | openai.AudioRequest{ 69 | Model: openai.Whisper1, 70 | FilePath: path, 71 | }) 72 | if err != nil { 73 | c.hal.Debug(fmt.Sprintf("transcription error: %#v\n", err)) 74 | return stateReady 75 | } 76 | 77 | c.hal.Debug(fmt.Sprintf("User: %s \n", transcription.Text)) 78 | 79 | if next, ok := c.handleUserCommands(transcription.Text); ok { 80 | return next 81 | } 82 | 83 | // Moderate messages. 84 | if ok := c.moderator.Moderate(transcription.Text); !ok { 85 | c.errorMessage = "I can't talk about this" 86 | return stateError 87 | } 88 | 89 | c.chat.Messages = append(c.chat.Messages, openai.ChatCompletionMessage{ 90 | Role: openai.ChatMessageRoleUser, 91 | Content: transcription.Text, 92 | }) 93 | 94 | c.refreshChatRequest() 95 | resp, err := c.openai.CreateChatCompletion(ctx, *c.chat) 96 | if err != nil { 97 | c.hal.Debug(fmt.Sprintf("chat error: %#v\n", err)) 98 | c.chat.Messages = c.chat.Messages[:len(c.chat.Messages)-1] // Remove the last message. 99 | return stateReady 100 | } 101 | 102 | c.hal.Debug(resp.Choices[0].Message.Content) 103 | c.chat.Messages = append(c.chat.Messages, resp.Choices[0].Message) 104 | c.lastChat = time.Now() // Success, update last chat time. 105 | 106 | return stateTalking 107 | } 108 | 109 | func (c *chatbox) handleUserCommands(input string) (state, bool) { 110 | input = strings.TrimSuffix(strings.ToLower(input), ".") 111 | if input == "shutdown" || input == "shut down" { 112 | return stateShutdown, true 113 | } 114 | if strings.HasPrefix(input, "change personality to ") { 115 | c.personality = strings.TrimPrefix(input, "change personality to ") 116 | return stateChange, true 117 | } 118 | 119 | return stateThinking, false 120 | } 121 | -------------------------------------------------------------------------------- /docs/chatbox-tui.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoani/chatbox/0b60053d78b41ff92ad5d65a4def1979cc2ca0c8/docs/chatbox-tui.gif -------------------------------------------------------------------------------- /docs/chatbox.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoani/chatbox/0b60053d78b41ff92ad5d65a4def1979cc2ca0c8/docs/chatbox.webp -------------------------------------------------------------------------------- /docs/i2cpinout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoani/chatbox/0b60053d78b41ff92ad5d65a4def1979cc2ca0c8/docs/i2cpinout.png -------------------------------------------------------------------------------- /docs/pbpinout.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hoani/chatbox/0b60053d78b41ff92ad5d65a4def1979cc2ca0c8/docs/pbpinout.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hoani/chatbox 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/charmbracelet/bubbletea v0.23.2 7 | github.com/charmbracelet/lipgloss v0.7.1 8 | github.com/faiface/beep v1.1.0 9 | github.com/hoani/toot v0.0.0-20230421055740-eb2f95386d80 10 | github.com/pkg/errors v0.9.1 11 | github.com/sashabaranov/go-openai v1.7.0 12 | github.com/stianeikeland/go-rpio/v4 v4.6.0 13 | github.com/stretchr/testify v1.8.2 14 | github.com/tarm/goserial v0.0.0-20151007205400-b3440c3c6355 15 | ) 16 | 17 | require ( 18 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 19 | github.com/containerd/console v1.0.3 // indirect 20 | github.com/davecgh/go-spew v1.1.1 // indirect 21 | github.com/gordonklaus/portaudio v0.0.0-20221027163845-7c3b689db3cc // indirect 22 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 23 | github.com/mattn/go-isatty v0.0.17 // indirect 24 | github.com/mattn/go-localereader v0.0.1 // indirect 25 | github.com/mattn/go-runewidth v0.0.14 // indirect 26 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect 27 | github.com/muesli/cancelreader v0.2.2 // indirect 28 | github.com/muesli/reflow v0.3.0 // indirect 29 | github.com/muesli/termenv v0.15.1 // indirect 30 | github.com/pmezard/go-difflib v1.0.0 // indirect 31 | github.com/rivo/uniseg v0.2.0 // indirect 32 | golang.org/x/sync v0.1.0 // indirect 33 | golang.org/x/sys v0.6.0 // indirect 34 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 // indirect 35 | golang.org/x/text v0.3.7 // indirect 36 | gonum.org/v1/gonum v0.12.0 // indirect 37 | gopkg.in/yaml.v3 v3.0.1 // indirect 38 | ) 39 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= 2 | github.com/aymanbagabas/go-osc52 v1.2.1/go.mod h1:zT8H+Rk4VSabYN90pWyugflM3ZhpTZNC7cASDfUCdT4= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/charmbracelet/bubbletea v0.23.2 h1:vuUJ9HJ7b/COy4I30e8xDVQ+VRDUEFykIjryPfgsdps= 6 | github.com/charmbracelet/bubbletea v0.23.2/go.mod h1:FaP3WUivcTM0xOKNmhciz60M6I+weYLF76mr1JyI7sM= 7 | github.com/charmbracelet/lipgloss v0.7.1 h1:17WMwi7N1b1rVWOjMT+rCh7sQkvDU75B2hbZpc5Kc1E= 8 | github.com/charmbracelet/lipgloss v0.7.1/go.mod h1:yG0k3giv8Qj8edTCbbg6AlQ5e8KNWpFujkNawKNhE2c= 9 | github.com/containerd/console v1.0.3 h1:lIr7SlA5PxZyMV30bDW0MGbiOPXwc63yRuCP0ARubLw= 10 | github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= 11 | github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= 12 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 13 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 14 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 15 | github.com/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= 16 | github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= 17 | github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= 18 | github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= 19 | github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= 20 | github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= 21 | github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= 22 | github.com/gordonklaus/portaudio v0.0.0-20221027163845-7c3b689db3cc h1:yYLpN7bJxKYILKnk20oczGQOQd2h3/7z7/cxdD9Se/I= 23 | github.com/gordonklaus/portaudio v0.0.0-20221027163845-7c3b689db3cc/go.mod h1:WY8R6YKlI2ZI3UyzFk7P6yGSuS+hFwNtEzrexRyD7Es= 24 | github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= 25 | github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= 26 | github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= 27 | github.com/hoani/toot v0.0.0-20230414035529-787ac52d6d01 h1:m32BJ6DdCNkqJVHZRJxuO7wOsU99ifqXvUtqtuapftw= 28 | github.com/hoani/toot v0.0.0-20230414035529-787ac52d6d01/go.mod h1:lujYq6Yam3iTZeyqs6j3COLRzmy53UPr6u9QmDBF9Vg= 29 | github.com/hoani/toot v0.0.0-20230421055740-eb2f95386d80 h1:N+H7C4JHPZOjnrSJy1kPsWNn04NcOV91QI7dc8czsYs= 30 | github.com/hoani/toot v0.0.0-20230421055740-eb2f95386d80/go.mod h1:lujYq6Yam3iTZeyqs6j3COLRzmy53UPr6u9QmDBF9Vg= 31 | github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= 32 | github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= 33 | github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= 34 | github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= 35 | github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= 36 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 37 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 38 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 39 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 40 | github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= 41 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= 42 | github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= 43 | github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= 44 | github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 45 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 46 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 47 | github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= 48 | github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= 49 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34= 50 | github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho= 51 | github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= 52 | github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= 53 | github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= 54 | github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= 55 | github.com/muesli/termenv v0.14.0/go.mod h1:kG/pF1E7fh949Xhe156crRUrHNyK221IuGO7Ez60Uc8= 56 | github.com/muesli/termenv v0.15.1 h1:UzuTb/+hhlBugQz28rpzey4ZuKcZ03MeKsoG7IJZIxs= 57 | github.com/muesli/termenv v0.15.1/go.mod h1:HeAQPTzpfs016yGtA4g00CsdYnVLJvxsS4ANqrZs2sQ= 58 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 59 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 60 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 61 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 62 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 63 | github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 64 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 65 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 66 | github.com/sashabaranov/go-openai v1.7.0 h1:D1dBXoZhtf/aKNu6WFf0c7Ah2NM30PZ/3Mqly6cZ7fk= 67 | github.com/sashabaranov/go-openai v1.7.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg= 68 | github.com/stianeikeland/go-rpio/v4 v4.6.0 h1:eAJgtw3jTtvn/CqwbC82ntcS+dtzUTgo5qlZKe677EY= 69 | github.com/stianeikeland/go-rpio/v4 v4.6.0/go.mod h1:A3GvHxC1Om5zaId+HqB3HKqx4K/AqeckxB7qRjxMK7o= 70 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 71 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 72 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 73 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 74 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 75 | github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= 76 | github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 77 | github.com/tarm/goserial v0.0.0-20151007205400-b3440c3c6355 h1:Kp3kg8YL2dc75mckomrHZQTfzNyFGnaqFhJeQw4ozGc= 78 | github.com/tarm/goserial v0.0.0-20151007205400-b3440c3c6355/go.mod h1:jcMo2Odv5FpDA6rp8bnczbUolcICW6t54K3s9gOlgII= 79 | golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 80 | golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs= 81 | golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 82 | golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= 83 | golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= 84 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 85 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 86 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 87 | golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 88 | golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 89 | golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 90 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 91 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 92 | golang.org/x/sys v0.0.0-20220204135822-1c1b9b1eba6a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 93 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 94 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 95 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 96 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 97 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 98 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 99 | golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= 100 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 101 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 102 | gonum.org/v1/gonum v0.12.0 h1:xKuo6hzt+gMav00meVPUlXwSdoEJP46BR+wdxQEFK2o= 103 | gonum.org/v1/gonum v0.12.0/go.mod h1:73TDxJfAAHeA8Mk9mf8NlIppyhQNo5GLTcYeqgo2lvY= 104 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 105 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 106 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | -------------------------------------------------------------------------------- /hal/button_darwin.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | ) 7 | 8 | type button struct { 9 | value bool 10 | lock sync.Mutex 11 | } 12 | 13 | func (b *button) start() { 14 | go func() { 15 | for { 16 | fmt.Scanln() 17 | b.lock.Lock() 18 | b.value = !b.value 19 | b.lock.Unlock() 20 | } 21 | }() 22 | } 23 | 24 | func (b *button) get() bool { 25 | b.lock.Lock() 26 | defer b.lock.Unlock() 27 | return b.value 28 | } 29 | -------------------------------------------------------------------------------- /hal/hal.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | type LCDColor uint 4 | 5 | const ( 6 | LCDRed LCDColor = iota 7 | LCDGreen 8 | LCDAqua 9 | LCDBlue 10 | ) 11 | 12 | type HSV struct { 13 | H, S, V uint8 14 | } 15 | 16 | type RGB struct { 17 | R, G, B uint8 18 | } 19 | 20 | type Leds interface { 21 | HSV(i int, values ...HSV) 22 | RGB(i int, values ...RGB) 23 | Show() 24 | Clear() 25 | } 26 | 27 | type LCD interface { 28 | Write(line1, line2 string, color LCDColor) 29 | } 30 | 31 | type Hal interface { 32 | Button() bool 33 | Leds() Leds 34 | LCD() LCD 35 | Debug(string) 36 | Shutdown() 37 | } 38 | 39 | func NewHal() (Hal, error) { 40 | return newHal() 41 | } 42 | -------------------------------------------------------------------------------- /hal/hal_darwin.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "os" 5 | 6 | tea "github.com/charmbracelet/bubbletea" 7 | "github.com/hoani/chatbox/hal/ui" 8 | ) 9 | 10 | type hal struct { 11 | button *button 12 | leds *leds 13 | lcd *lcd 14 | prog *tea.Program 15 | m *ui.Model 16 | } 17 | 18 | func newHal() (*hal, error) { 19 | m := ui.NewUI() 20 | prog := tea.NewProgram(m) 21 | 22 | h := &hal{ 23 | lcd: newLCD(prog), 24 | leds: newLeds(prog), 25 | prog: prog, 26 | m: m, 27 | } 28 | 29 | go prog.Run() 30 | return h, nil 31 | } 32 | 33 | func (h *hal) Debug(msg string) { 34 | h.prog.Send(ui.Debug(msg)) 35 | } 36 | 37 | func (h *hal) Leds() Leds { 38 | return h.leds 39 | } 40 | 41 | func (h *hal) LCD() LCD { 42 | return h.lcd 43 | } 44 | 45 | func (h *hal) Button() bool { 46 | return h.m.ButtonState() 47 | } 48 | 49 | func (h *hal) Shutdown() { 50 | os.Exit(0) 51 | } 52 | -------------------------------------------------------------------------------- /hal/hal_linux.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | 7 | "github.com/stianeikeland/go-rpio/v4" 8 | ) 9 | 10 | const buttonPin = 5 11 | 12 | type hal struct { 13 | button rpio.Pin 14 | leds *leds 15 | lcd *lcd 16 | } 17 | 18 | func newHal() (*hal, error) { 19 | if err := rpio.Open(); err != nil { 20 | return nil, err 21 | } 22 | 23 | button := rpio.Pin(buttonPin) 24 | button.Input() 25 | button.PullUp() 26 | 27 | leds, err := newLeds() 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | return &hal{ 33 | button: button, 34 | leds: leds, 35 | lcd: newLCD(), 36 | }, nil 37 | } 38 | 39 | func (h *hal) Button() bool { 40 | return h.button.Read() == rpio.Low // Note: reverse polarity, High means unpressed. 41 | } 42 | 43 | func (h *hal) Leds() Leds { 44 | return h.leds 45 | } 46 | 47 | func (h *hal) LCD() LCD { 48 | return h.lcd 49 | } 50 | 51 | func (h *hal) Debug(s string) { 52 | fmt.Println(s) 53 | } 54 | 55 | func (h *hal) Shutdown() { 56 | exec.Command("sudo", "shutdown", "now").Run() 57 | } 58 | -------------------------------------------------------------------------------- /hal/lcd_darwin.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | tea "github.com/charmbracelet/bubbletea" 5 | "github.com/charmbracelet/lipgloss" 6 | "github.com/hoani/chatbox/hal/ui" 7 | ) 8 | 9 | type lcd struct { 10 | prog *tea.Program 11 | } 12 | 13 | func newLCD(prog *tea.Program) *lcd { 14 | return &lcd{prog: prog} 15 | } 16 | 17 | func (l *lcd) Write(line1, line2 string, color LCDColor) { 18 | c := LCDtoLipglossColor(color) 19 | l.prog.Send(ui.LCDUpdate{ 20 | Color: &c, 21 | Lines: [2]string{ 22 | line1 + " ", 23 | line2 + " ", 24 | }, 25 | }) 26 | } 27 | 28 | func LCDtoLipglossColor(in LCDColor) lipgloss.Color { 29 | switch in { 30 | case LCDRed: 31 | return lipgloss.Color("#ff0000") 32 | case LCDGreen: 33 | return lipgloss.Color("#00ff00") 34 | case LCDAqua: 35 | return lipgloss.Color("#00ffff") 36 | case LCDBlue: 37 | return lipgloss.Color("#0000ff") 38 | default: 39 | return lipgloss.Color("#444444") 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /hal/lcd_linux.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | cmd "github.com/hoani/chatbox/lcd" 5 | ) 6 | 7 | type lcd struct { 8 | line1 string 9 | line2 string 10 | color LCDColor 11 | } 12 | 13 | func newLCD() *lcd { 14 | cmd.Cmd().Init().Run() 15 | 16 | return &lcd{ 17 | color: LCDRed, 18 | } 19 | } 20 | 21 | func (l *lcd) Write(line1, line2 string, color LCDColor) { 22 | changed := false 23 | if l.line1 != line1 { 24 | changed = true 25 | l.line1 = line1 26 | } 27 | if l.line2 != line2 { 28 | changed = true 29 | l.line2 = line2 30 | } 31 | if l.color != color { 32 | changed = true 33 | l.color = color 34 | } 35 | if changed { 36 | cmd.Cmd().Line1(l.line1).Line2(l.line2).RGB(LCDColorToString(l.color)).Run() 37 | } 38 | } 39 | 40 | func LCDColorToString(color LCDColor) string { 41 | switch color { 42 | case LCDRed: 43 | return "255,0,0" 44 | case LCDGreen: 45 | return "255,209,0" 46 | case LCDAqua: 47 | return "248,248,60" 48 | case LCDBlue: 49 | return "255,255,255" 50 | default: 51 | return "255,255,255" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /hal/leds_darwin.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/hoani/chatbox/hal/ui" 10 | ) 11 | 12 | type leds struct { 13 | prog *tea.Program 14 | } 15 | 16 | func newLeds(prog *tea.Program) *leds { 17 | return &leds{prog: prog} 18 | } 19 | 20 | func (l *leds) HSV(index int, values ...HSV) { 21 | rgbs := make([]RGB, len(values)) 22 | for i, value := range values { 23 | rgbs[i] = HSVtoRGB(value) 24 | } 25 | l.RGB(index, rgbs...) 26 | } 27 | 28 | func (l *leds) RGB(index int, values ...RGB) { 29 | 30 | colors := make([]lipgloss.Color, len(values)) 31 | for i, value := range values { 32 | colors[i] = RGBtoLipglossColor(value) 33 | } 34 | 35 | l.prog.Send(ui.LEDColors{ 36 | Index: index, 37 | Colors: colors, 38 | }) 39 | 40 | } 41 | func (l *leds) Show() { 42 | l.prog.Send(ui.LEDShow{}) 43 | } 44 | func (l *leds) Clear() { 45 | l.prog.Send(ui.LEDClear{}) 46 | 47 | } 48 | 49 | func RGBtoLipglossColor(in RGB) lipgloss.Color { 50 | return lipgloss.Color(fmt.Sprintf("#%02x%02x%02x", in.R, in.G, in.B)) 51 | } 52 | 53 | // https://www.rapidtables.com/convert/color/hsv-to-rgb.html 54 | func HSVtoRGB(in HSV) RGB { 55 | c := (float64(in.V) / 256.0) * (float64(in.S) / 256.0) 56 | theta := 360.0 * float64(in.H) / 256.0 57 | x := c * (1 - math.Abs(float64(int(theta/60.0)%2)-1)) 58 | m := (float64(in.V) / 256.0) - c 59 | 60 | c = c * 256 61 | x = x * 256 62 | m = m * 256 63 | 64 | if theta < 60 { 65 | return RGB{R: uint8(c + m), G: uint8(x + m), B: uint8(m)} 66 | } else if theta < 120 { 67 | return RGB{R: uint8(x + m), G: uint8(c + m), B: uint8(m)} 68 | } else if theta < 180 { 69 | return RGB{R: uint8(m), G: uint8(c + m), B: uint8(x + m)} 70 | } else if theta < 240 { 71 | return RGB{R: uint8(m), G: uint8(x + m), B: uint8(c + m)} 72 | } else if theta < 300 { 73 | return RGB{R: uint8(x + m), G: uint8(m), B: uint8(c + m)} 74 | } else { 75 | return RGB{R: uint8(c + m), G: uint8(m), B: uint8(x + m)} 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /hal/leds_linux.go: -------------------------------------------------------------------------------- 1 | package hal 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "os" 7 | "io" 8 | "errors" 9 | 10 | "github.com/tarm/goserial" 11 | ) 12 | 13 | const numLeds = 24 14 | 15 | type leds struct { 16 | s io.ReadWriteCloser 17 | } 18 | 19 | func newLeds() (*leds, error) { 20 | port, err := findPort() 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | c := &serial.Config{Name: port, Baud: 115200} 26 | s, err := serial.OpenPort(c) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | // Initialize the LEDs to 24, clear and show. 32 | _, err = s.Write([]byte(fmt.Sprintf("I%02x\nC\nS\n", numLeds))) 33 | if err != nil { 34 | return nil, err 35 | } 36 | return &leds{ 37 | s: s, 38 | }, nil 39 | } 40 | 41 | func findPort() (string, error) { 42 | contents, _ := os.ReadDir("/dev") 43 | 44 | // Look for what is mostly likely the Arduino device 45 | for _, f := range contents { 46 | if strings.Contains(f.Name(), "ttyACM") { 47 | return "/dev/" + f.Name(), nil 48 | } 49 | } 50 | 51 | // Have not been able to find a USB device that 'looks' 52 | // like an Arduino. 53 | return "", errors.New("cannot find USB device") 54 | } 55 | 56 | 57 | func (l *leds) HSV(i int, values ...HSV){ 58 | if len(values) == 0 { 59 | return 60 | } 61 | msg := fmt.Sprintf("H%02x", i) 62 | for _, v := range values { 63 | msg += fmt.Sprintf("%02x%02x%02x", v.H, v.S, v.V) 64 | } 65 | msg += "\n" 66 | _, err := l.s.Write([]byte(msg)) 67 | if err != nil { 68 | fmt.Println("error writing to serial " + err.Error()) 69 | } 70 | } 71 | func (l *leds) RGB(i int, values ...RGB){ 72 | if len(values) == 0 { 73 | return 74 | } 75 | msg := fmt.Sprintf("R%02x", i) 76 | for _, v := range values { 77 | msg += fmt.Sprintf("%02x%02x%02x", v.R, v.G, v.B) 78 | } 79 | msg += "\n" 80 | _, err := l.s.Write([]byte(msg)) 81 | if err != nil { 82 | fmt.Println("error writing to serial " + err.Error()) 83 | } 84 | } 85 | func (l *leds) Show(){ 86 | _, err := l.s.Write([]byte("S\n")) 87 | if err != nil { 88 | fmt.Println("error writing to serial " + err.Error()) 89 | } 90 | } 91 | func (l *leds) Clear(){ 92 | _, err := l.s.Write([]byte("C\n")) 93 | if err != nil { 94 | fmt.Println("error writing to serial " + err.Error()) 95 | } 96 | } 97 | 98 | 99 | -------------------------------------------------------------------------------- /hal/ui/model.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | tea "github.com/charmbracelet/bubbletea" 8 | "github.com/charmbracelet/lipgloss" 9 | "github.com/hoani/chatbox/strutil" 10 | ) 11 | 12 | var helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262")).Render 13 | 14 | var viewCount = 0 15 | var updateCount = 0 16 | 17 | const ( 18 | padding = 2 19 | maxWidth = 80 20 | nLEDs = 24 21 | LCDWidth = 16 22 | LCDHeight = 2 23 | ) 24 | 25 | type LEDColors struct { 26 | Index int 27 | Colors []lipgloss.Color 28 | } 29 | 30 | type Debug string 31 | 32 | type LEDShow struct{} 33 | 34 | type LEDClear struct{} 35 | 36 | type LCDUpdate struct { 37 | Color *lipgloss.Color 38 | Lines [LCDHeight]string 39 | } 40 | 41 | type Model struct { 42 | pending [nLEDs]lipgloss.Color 43 | leds [nLEDs]string 44 | lcd LCDUpdate 45 | lcdStyle lipgloss.Style 46 | debug string 47 | buttonState bool 48 | width int 49 | } 50 | 51 | func NewUI() *Model { 52 | colors := [nLEDs]lipgloss.Color{} 53 | leds := [nLEDs]string{} 54 | for i := range colors { 55 | colors[i] = lipgloss.Color("#000000") 56 | leds[i] = lipgloss.NewStyle().Foreground(colors[i]).Render(" ") 57 | } 58 | lcdColor := lipgloss.Color("#0000ee") 59 | 60 | return &Model{ 61 | pending: colors, 62 | leds: leds, 63 | lcd: LCDUpdate{ 64 | Color: &lcdColor, 65 | Lines: [LCDHeight]string{}, 66 | }, 67 | lcdStyle: lipgloss.NewStyle(). 68 | ColorWhitespace(true). 69 | MaxWidth(4 + LCDWidth).PaddingLeft(2).PaddingRight(2). 70 | Background(lcdColor). 71 | Foreground(lipgloss.Color("#eeeeee")), 72 | width: 100, 73 | } 74 | } 75 | 76 | func (m *Model) Init() tea.Cmd { 77 | return nil 78 | } 79 | 80 | func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 81 | updateCount++ 82 | switch msg := msg.(type) { 83 | case tea.KeyMsg: 84 | switch msg.Type { 85 | case tea.KeyCtrlC: 86 | return m, tea.Quit 87 | case tea.KeySpace: 88 | m.buttonState = !m.buttonState 89 | return m, nil 90 | } 91 | 92 | case LEDColors: 93 | for i, color := range msg.Colors { 94 | if i+msg.Index < nLEDs { 95 | m.pending[i] = color 96 | } 97 | } 98 | case LEDShow: 99 | for i, color := range m.pending { 100 | m.leds[i] = lipgloss.NewStyle().Foreground(color).Render("✮") 101 | } 102 | 103 | case LEDClear: 104 | for i := range m.pending { 105 | m.pending[i] = lipgloss.Color("#000000") 106 | } 107 | 108 | case LCDUpdate: 109 | m.lcd.Lines = msg.Lines 110 | for i, line := range m.lcd.Lines { 111 | if padding := LCDWidth - len(line); padding > 0 { 112 | m.lcd.Lines[i] = line + strings.Repeat(" ", padding) 113 | } 114 | } 115 | if msg.Color != nil { 116 | m.lcd.Color = msg.Color 117 | m.lcdStyle = m.lcdStyle.Background(m.lcd.Color) 118 | } 119 | 120 | case tea.WindowSizeMsg: 121 | m.width = msg.Width 122 | 123 | case Debug: 124 | m.debug = string(msg) 125 | } 126 | return m, nil 127 | } 128 | 129 | func (m *Model) View() string { 130 | 131 | return m.ViewLEDs() + "\n" + 132 | helpStyle("Press space to talk/stop talking, press Ctl-C to quit UI") 133 | } 134 | 135 | func (m *Model) ViewLEDs() string { 136 | debugSplit := strutil.SplitWidth(m.debug, m.width) 137 | 138 | return fmt.Sprintf(` 139 | %s %s %s %s 140 | %s %s 141 | %s %s 142 | %s %s 143 | %s %s %s 144 | %s %s %s 145 | %s %s 146 | %s %s 147 | %s %s 148 | %s %s %s %s 149 | 150 | %s 151 | `, m.leds[0], m.leds[1], m.leds[2], m.leds[3], 152 | m.leds[nLEDs-1], m.leds[4], 153 | m.leds[nLEDs-2], m.leds[5], 154 | m.leds[nLEDs-3], m.leds[6], 155 | m.leds[nLEDs-4], m.leds[7], m.lcdStyle.Render(m.lcd.Lines[0]), 156 | m.leds[nLEDs-5], m.leds[8], m.lcdStyle.Render(m.lcd.Lines[1]), 157 | m.leds[nLEDs-6], m.leds[9], 158 | m.leds[nLEDs-7], m.leds[10], 159 | m.leds[nLEDs-8], m.leds[11], 160 | m.leds[15], m.leds[14], m.leds[13], m.leds[12], 161 | strings.Join(debugSplit, "\n"), 162 | ) 163 | } 164 | 165 | func (m *Model) ButtonState() bool { 166 | return m.buttonState 167 | } 168 | -------------------------------------------------------------------------------- /lcd/README.md: -------------------------------------------------------------------------------- 1 | RGB1602 is not my code, but distributed by the LCD manufacturer. 2 | 3 | It is not covered by any license in this repository. -------------------------------------------------------------------------------- /lcd/RGB1602.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import time 3 | from smbus import SMBus 4 | b = SMBus(1) 5 | 6 | #Device I2C Arress 7 | LCD_ADDRESS = (0x7c>>1) #0x3e 8 | RGB_ADDRESS = (0xc0>>1) #0x70 9 | 10 | #color define 11 | 12 | REG_RED = 0x04 13 | REG_GREEN = 0x03 14 | REG_BLUE = 0x02 15 | REG_MODE1 = 0x00 16 | REG_MODE2 = 0x01 17 | REG_OUTPUT = 0x08 18 | LCD_CLEARDISPLAY = 0x01 19 | LCD_RETURNHOME = 0x02 20 | LCD_ENTRYMODESET = 0x04 21 | LCD_DISPLAYCONTROL = 0x08 22 | LCD_CURSORSHIFT = 0x10 23 | LCD_FUNCTIONSET = 0x20 24 | LCD_SETCGRAMADDR = 0x40 25 | LCD_SETDDRAMADDR = 0x80 26 | 27 | #flags for display entry mode 28 | LCD_ENTRYRIGHT = 0x00 29 | LCD_ENTRYLEFT = 0x02 30 | LCD_ENTRYSHIFTINCREMENT = 0x01 31 | LCD_ENTRYSHIFTDECREMENT = 0x00 32 | 33 | #flags for display on/off control 34 | LCD_DISPLAYON = 0x04 35 | LCD_DISPLAYOFF = 0x00 36 | LCD_CURSORON = 0x02 37 | LCD_CURSOROFF = 0x00 38 | LCD_BLINKON = 0x01 39 | LCD_BLINKOFF = 0x00 40 | 41 | #flags for display/cursor shift 42 | LCD_DISPLAYMOVE = 0x08 43 | LCD_CURSORMOVE = 0x00 44 | LCD_MOVERIGHT = 0x04 45 | LCD_MOVELEFT = 0x00 46 | 47 | #flags for function set 48 | LCD_8BITMODE = 0x10 49 | LCD_4BITMODE = 0x00 50 | LCD_2LINE = 0x08 51 | LCD_1LINE = 0x00 52 | LCD_5x8DOTS = 0x00 53 | 54 | 55 | class RGB1602: 56 | def __init__(self, col, row): 57 | self._row = row 58 | self._col = col 59 | self._showfunction = LCD_4BITMODE | LCD_1LINE | LCD_5x8DOTS; 60 | self.begin(self._row,self._col) 61 | 62 | 63 | def command(self,cmd): 64 | b.write_byte_data(LCD_ADDRESS,0x80,cmd) 65 | 66 | def write(self,data): 67 | b.write_byte_data(LCD_ADDRESS,0x40,data) 68 | 69 | def setReg(self,reg,data): 70 | b.write_byte_data(RGB_ADDRESS,reg,data) 71 | 72 | 73 | def setRGB(self,r,g,b): 74 | self.setReg(REG_RED,r) 75 | self.setReg(REG_GREEN,g) 76 | self.setReg(REG_BLUE,b) 77 | 78 | def setCursor(self,col,row): 79 | if(row == 0): 80 | col|=0x80 81 | else: 82 | col|=0xc0; 83 | self.command(col) 84 | 85 | def clear(self): 86 | self.command(LCD_CLEARDISPLAY) 87 | time.sleep(0.002) 88 | def printout(self,arg): 89 | if(isinstance(arg,int)): 90 | arg=str(arg) 91 | 92 | for x in bytearray(arg,'utf-8'): 93 | self.write(x) 94 | 95 | 96 | def display(self): 97 | self._showcontrol |= LCD_DISPLAYON 98 | self.command(LCD_DISPLAYCONTROL | self._showcontrol) 99 | 100 | 101 | def begin(self,cols,lines): 102 | if (lines > 1): 103 | self._showfunction |= LCD_2LINE 104 | 105 | self._numlines = lines 106 | self._currline = 0 107 | 108 | time.sleep(0.05) 109 | 110 | def initializeRegisters(self): 111 | # Send function set command sequence 112 | self.command(LCD_FUNCTIONSET | self._showfunction) 113 | #delayMicroseconds(4500); # wait more than 4.1ms 114 | time.sleep(0.005) 115 | # second try 116 | self.command(LCD_FUNCTIONSET | self._showfunction); 117 | #delayMicroseconds(150); 118 | time.sleep(0.005) 119 | # third go 120 | self.command(LCD_FUNCTIONSET | self._showfunction) 121 | # finally, set # lines, font size, etc. 122 | self.command(LCD_FUNCTIONSET | self._showfunction) 123 | # turn the display on with no cursor or blinking default 124 | self._showcontrol = LCD_DISPLAYON | LCD_CURSOROFF | LCD_BLINKOFF 125 | self.display() 126 | # clear it off 127 | self.clear() 128 | # Initialize to default text direction (for romance languages) 129 | self._showmode = LCD_ENTRYLEFT | LCD_ENTRYSHIFTDECREMENT 130 | # set the entry mode 131 | self.command(LCD_ENTRYMODESET | self._showmode); 132 | 133 | # backlight init 134 | self.setReg(REG_MODE1, 0) 135 | # set LEDs controllable by both PWM and GRPPWM registers 136 | self.setReg(REG_OUTPUT, 0xFF) 137 | # set MODE2 values 138 | # 0010 0000 -> 0x20 (DMBLNK to 1, ie blinky mode) 139 | self.setReg(REG_MODE2, 0x20) 140 | 141 | def setColorWhite(self): 142 | self.setRGB(255, 255, 255) 143 | -------------------------------------------------------------------------------- /lcd/lcd.go: -------------------------------------------------------------------------------- 1 | package lcd 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | ) 7 | 8 | const ( 9 | Width = 16 10 | Height = 2 11 | ) 12 | 13 | type cmd []string 14 | 15 | func Cmd() cmd { 16 | return make(cmd, 0) 17 | } 18 | 19 | func (c cmd) Line1(in string) cmd { 20 | return append(c, "--line1", Whitespace(in)) 21 | } 22 | 23 | func (c cmd) Line2(in string) cmd { 24 | return append(c, "--line2", Whitespace(in)) 25 | } 26 | 27 | func (c cmd) RGB(in string) cmd { 28 | return append(c, "--rgb", in) 29 | } 30 | 31 | func (c cmd) Init() cmd { 32 | return append(c, "--init", "true") 33 | } 34 | 35 | func (c cmd) Run() { 36 | args := []string{"lcd/lcd.py"} 37 | for _, v := range c { 38 | args = append(args, v) 39 | } 40 | exec.Command("python3", args...).Run() 41 | } 42 | 43 | func Pad(in string) string { 44 | if len(in) > Width { 45 | return in[:Width] 46 | } 47 | padding := (Width - len(in)) / 2 48 | return strings.Repeat(" ", padding) + in + strings.Repeat(" ", padding) 49 | } 50 | 51 | func Whitespace(in string) string { 52 | if diff := Width - len(in); diff > 0 { 53 | return in + strings.Repeat(" ", diff) 54 | } 55 | return in 56 | } 57 | -------------------------------------------------------------------------------- /lcd/lcd.py: -------------------------------------------------------------------------------- 1 | import RGB1602 2 | import argparse 3 | 4 | lcd=RGB1602.RGB1602(16,2) 5 | 6 | parser = argparse.ArgumentParser(description='RGB1602 control') 7 | parser.add_argument("--init") 8 | parser.add_argument("--line1") 9 | parser.add_argument("--line2") 10 | parser.add_argument("--rgb") 11 | 12 | args = parser.parse_args() 13 | 14 | if args.init: 15 | lcd.initializeRegisters() 16 | 17 | if args.rgb: 18 | [r,g,b]=args.rgb.split(',', 3) 19 | r = int(r) 20 | g = int(g) 21 | b = int(b) 22 | 23 | lcd.setRGB(r, g, b) 24 | 25 | if args.line1: 26 | lcd.setCursor(0, 0) 27 | lcd.printout(args.line1) 28 | 29 | if args.line2: 30 | lcd.setCursor(0, 1) 31 | lcd.printout(args.line2) 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /leds/audioSink.go: -------------------------------------------------------------------------------- 1 | package leds 2 | 3 | import ( 4 | "github.com/faiface/beep" 5 | ) 6 | 7 | type Sink struct { 8 | stream beep.Streamer 9 | } 10 | 11 | func NewSink(stream beep.Streamer) *Sink { 12 | return &Sink{stream: stream} 13 | } 14 | 15 | func (s *Sink) Run() { 16 | var samples = make([][2]float64, 128) 17 | for { 18 | if _, ok := s.stream.Stream(samples); !ok { 19 | return 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /leds/visualizer.go: -------------------------------------------------------------------------------- 1 | package leds 2 | 3 | import ( 4 | "context" 5 | "math" 6 | "sync" 7 | "time" 8 | 9 | "github.com/faiface/beep" 10 | "github.com/hoani/toot" 11 | ) 12 | 13 | const NChannels = 12 14 | 15 | type Visualizer interface { 16 | Channels() [NChannels]float64 17 | Wait() 18 | } 19 | 20 | type Source struct { 21 | Streamer beep.Streamer 22 | SampleRate beep.SampleRate 23 | } 24 | 25 | type visualizer struct { 26 | channels [NChannels]float64 27 | channelsLock sync.Mutex 28 | sink func(s beep.Streamer) 29 | source *Source 30 | wg sync.WaitGroup 31 | } 32 | 33 | func NewVisualizer(options ...func(*visualizer)) *visualizer { 34 | v := &visualizer{} 35 | for _, o := range options { 36 | o(v) 37 | } 38 | if v.sink == nil { 39 | v.sink = func(s beep.Streamer) { 40 | d := NewSink(s) 41 | go d.Run() 42 | } 43 | } 44 | return v 45 | } 46 | 47 | func WithSink(sink func(s beep.Streamer)) func(*visualizer) { 48 | return func(v *visualizer) { 49 | v.sink = sink 50 | } 51 | } 52 | 53 | func WithSource(source *Source) func(*visualizer) { 54 | return func(v *visualizer) { 55 | v.source = source 56 | } 57 | } 58 | 59 | func (v *visualizer) Start(ctx context.Context) error { 60 | v.wg.Add(1) 61 | defer v.wg.Done() 62 | if v.source == nil { 63 | m, err := toot.NewDefaultMicrophone() 64 | if err != nil { 65 | return err 66 | } 67 | defer m.Close() 68 | m.SetSampleRate(m.Format().SampleRate / 8) // Reduce samples to reduce CPU usage. 69 | v.source = &Source{ 70 | Streamer: m, 71 | SampleRate: m.Format().SampleRate, 72 | } 73 | go m.Start(ctx) 74 | } 75 | 76 | a := toot.NewAnalyzer(v.source.Streamer, int(v.source.SampleRate), int(v.source.SampleRate/4)) 77 | tv := toot.NewVisualizer(100.0, 4000.0, NChannels) 78 | 79 | v.sink(a) 80 | 81 | for { 82 | select { 83 | case <-ctx.Done(): 84 | return nil 85 | case <-time.After(50 * time.Millisecond): 86 | s := a.GetPowerSpectrum() 87 | if s == nil { 88 | continue 89 | } 90 | 91 | result := tv.Bin(s) 92 | v.channelsLock.Lock() 93 | for i, r := range result { 94 | v.channels[i] = math.Log10(1+r*1000) * 5000 // Do some log scaling to make the power spectra show up nicer. 95 | } 96 | v.channelsLock.Unlock() 97 | } 98 | } 99 | } 100 | 101 | func (v *visualizer) Channels() [NChannels]float64 { 102 | v.channelsLock.Lock() 103 | defer v.channelsLock.Unlock() 104 | return v.channels 105 | } 106 | 107 | // Waits until the visualizer is closed. 108 | func (v *visualizer) Wait() { 109 | v.wg.Wait() 110 | } 111 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "time" 7 | 8 | "github.com/hoani/chatbox/app" 9 | "github.com/hoani/chatbox/hal" 10 | "github.com/hoani/chatbox/strutil" 11 | ) 12 | 13 | func main() { 14 | key := os.Getenv("OPENAI_KEY") 15 | cb, err := app.NewChatBox(key) 16 | if err != nil { 17 | handleError(err) 18 | } 19 | 20 | if err := cb.Run(); err != nil { 21 | handleError(err) 22 | } 23 | } 24 | 25 | // Attempts to report error by various means. 26 | func handleError(err error) { 27 | fmt.Println("program error: " + err.Error()) 28 | 29 | // Cool down for a while. We don't want to smash the processor with restart attempts. 30 | timer := time.NewTimer(time.Hour) 31 | h, e := hal.NewHal() 32 | if e != nil { 33 | <-timer.C 34 | os.Exit(1) 35 | } 36 | 37 | rgbs := []hal.RGB{} 38 | for i := 0; i < 24; i++ { 39 | rgbs = append(rgbs, hal.RGB{R: 0xFF, G: 0, B: 0}) 40 | } 41 | h.Leds().RGB(0, rgbs...) 42 | h.Leds().Show() 43 | 44 | msgs := strutil.SplitWidth(err.Error(), 16) 45 | msgs = append(msgs, "") 46 | if len(msgs)%2 != 0 { 47 | msgs = append(msgs, "") 48 | } 49 | index := 0 50 | 51 | for { 52 | select { 53 | case <-timer.C: 54 | os.Exit(1) 55 | case <-time.After(time.Second): 56 | h.LCD().Write(msgs[index%len(msgs)], msgs[(index+1)%len(msgs)], hal.LCDRed) 57 | index += 1 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /service/addService.sh: -------------------------------------------------------------------------------- 1 | mkdir -p $HOME/.config/systemd/user/ 2 | 3 | echo '[Unit] 4 | Description="Chatbox companion" 5 | After=pulseaudio.service 6 | Wants=pulseaudio.service 7 | 8 | [Service] 9 | WorkingDirectory='$(pwd)' 10 | ExecStart='$(pwd)'/chatbox 11 | StandardError=null 12 | Restart=always 13 | Environment="OPENAI_KEY='$OPENAI_KEY'" 14 | 15 | [Install] 16 | WantedBy=default.target' > $HOME/.config/systemd/user/chatbox.service 17 | -------------------------------------------------------------------------------- /strutil/strutil.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | func Simplify(input string) string { 8 | 9 | var vowels = map[rune]struct{}{ 10 | 'a': {}, 'e': {}, 'i': {}, 'o': {}, 'u': {}, 11 | 'A': {}, 'E': {}, 'I': {}, 'O': {}, 'U': {}, 12 | } 13 | 14 | runes := []rune(input) 15 | 16 | trimRepeatedPrefix := func(runes []rune) []rune { 17 | start := runes[0] 18 | var count int 19 | var r rune 20 | for _, r = range runes { 21 | if r != start { 22 | break 23 | } 24 | count++ 25 | } 26 | if count < 3 { 27 | return runes 28 | } 29 | if _, ok := vowels[start]; ok { 30 | return runes[count-2:] 31 | } 32 | return runes[count-1:] 33 | } 34 | 35 | index := 0 36 | for index < len(runes) { 37 | runes = append(runes[:index], trimRepeatedPrefix(runes[index:])...) 38 | index++ 39 | } 40 | 41 | return string(runes) 42 | } 43 | 44 | func SplitBrackets(input string) []string { 45 | result := []string{} 46 | current := "" 47 | depth := 0 48 | brace := "" 49 | braceMap := map[string]string{ 50 | ")": "(", 51 | "]": "[", 52 | "}": "{", 53 | ">": "<", 54 | "*": "*", 55 | } 56 | 57 | for _, c := range input { 58 | token := string(c) 59 | switch token { 60 | case "(", "[", "{", "<": 61 | if depth == 0 { 62 | brace = token 63 | if current := strings.TrimSpace(current); current != "" { 64 | result = append(result, current) 65 | } 66 | current = "" 67 | } 68 | current += token 69 | if token == brace { 70 | depth++ 71 | } 72 | case ")", "]", "}", ">": 73 | current += string(c) 74 | if depth > 0 && braceMap[token] == brace { 75 | depth-- 76 | if depth == 0 { 77 | result = append(result, strings.TrimSpace(current)) 78 | current = "" 79 | } 80 | } 81 | case "*": 82 | if depth == 0 { 83 | brace = token 84 | if current := strings.TrimSpace(current); current != "" { 85 | result = append(result, current) 86 | } 87 | current = token 88 | depth = 1 89 | } else if depth == 1 && brace == token { 90 | current += token 91 | result = append(result, strings.TrimSpace(current)) 92 | current = "" 93 | depth = 0 94 | } else { 95 | current += token 96 | } 97 | default: 98 | current += string(c) 99 | } 100 | } 101 | if current != "" { 102 | return append(result, strings.TrimSpace(current)) 103 | } 104 | return result 105 | } 106 | 107 | func SplitSentences(input string) []string { 108 | result := []string{} 109 | current := "" 110 | punctuation := "" 111 | for _, c := range input { 112 | token := string(c) 113 | switch token { 114 | case ".", "!", "?": 115 | if punctuation == "" { 116 | punctuation = token 117 | } 118 | current += token 119 | default: 120 | if punctuation != "" { 121 | result = append(result, strings.TrimSpace(current)) 122 | current = "" 123 | punctuation = "" 124 | } 125 | current += token 126 | } 127 | } 128 | if current != "" { 129 | return append(result, strings.TrimSpace(current)) 130 | } 131 | return result 132 | } 133 | 134 | func SplitWidth(input string, width int) []string { 135 | words := strings.Fields(input) 136 | lines := []string{} 137 | var line string 138 | for _, word := range words { 139 | if len(line)+len(word)+1 > width { 140 | if line != "" { 141 | lines = append(lines, line) 142 | } 143 | for len(word) > width { 144 | lines = append(lines, word[:width]) 145 | word = word[width:] 146 | } 147 | line = word 148 | continue 149 | } 150 | if line != "" { 151 | line += " " 152 | } 153 | line += word 154 | } 155 | if line != "" { 156 | lines = append(lines, line) 157 | } 158 | return lines 159 | } 160 | -------------------------------------------------------------------------------- /strutil/strutil_test.go: -------------------------------------------------------------------------------- 1 | package strutil 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/require" 7 | ) 8 | 9 | func TestSimplify(t *testing.T) { 10 | testCases := []struct { 11 | name string 12 | in string 13 | expected string 14 | }{ 15 | { 16 | name: "empty string", 17 | in: "", 18 | expected: "", 19 | }, 20 | { 21 | name: "basic sentence", 22 | in: "hello world", 23 | expected: "hello world", 24 | }, 25 | { 26 | name: "additional letters", 27 | in: "hhhhello worlllddd", 28 | expected: "hello world", 29 | }, 30 | { 31 | name: "limit vowels to 2", 32 | in: "hhhheeeelloo worllld", 33 | expected: "heelloo world", 34 | }, 35 | { 36 | name: "limit punctuation to 1", 37 | in: "hello world!!!!!!", 38 | expected: "hello world!", 39 | }, 40 | } 41 | 42 | for _, tc := range testCases { 43 | t.Run(tc.name, func(t *testing.T) { 44 | require.Equal(t, tc.expected, Simplify(tc.in)) 45 | }) 46 | } 47 | } 48 | 49 | func TestSplitBrackets(t *testing.T) { 50 | testCases := []struct { 51 | name string 52 | in string 53 | expected []string 54 | }{ 55 | { 56 | name: "empty string", 57 | in: "", 58 | expected: []string{}, 59 | }, 60 | { 61 | name: "no brackets", 62 | in: "hello world", 63 | expected: []string{"hello world"}, 64 | }, 65 | { 66 | name: "curved bracket complete", 67 | in: "hello (world) hello", 68 | expected: []string{"hello", "(world)", "hello"}, 69 | }, 70 | { 71 | name: "curved bracket incomplete", 72 | in: "hello (world", 73 | expected: []string{"hello", "(world"}, 74 | }, 75 | { 76 | name: "curved terminating bracket", 77 | in: "hello) world", 78 | expected: []string{"hello) world"}, 79 | }, 80 | { 81 | name: "curly bracket complete", 82 | in: "hello {world}", 83 | expected: []string{"hello", "{world}"}, 84 | }, 85 | { 86 | name: "square bracket complete", 87 | in: "hello [world]", 88 | expected: []string{"hello", "[world]"}, 89 | }, 90 | { 91 | name: "angle bracket complete", 92 | in: "hello ", 93 | expected: []string{"hello", ""}, 94 | }, 95 | { 96 | name: "curved and curly bracket complete", 97 | in: "hello (world) {how *are* you today?}", 98 | expected: []string{"hello", "(world)", "{how *are* you today?}"}, 99 | }, 100 | { 101 | name: "nested brackets", 102 | in: "hello (world {how (are) you today?})", 103 | expected: []string{"hello", "(world {how (are) you today?})"}, 104 | }, 105 | { 106 | name: "asterixes", 107 | in: "hello *world* how are you today?", 108 | expected: []string{"hello", "*world*", "how are you today?"}, 109 | }, 110 | } 111 | 112 | for _, tc := range testCases { 113 | t.Run(tc.name, func(t *testing.T) { 114 | actual := SplitBrackets(tc.in) 115 | require.Equal(t, tc.expected, actual) 116 | }) 117 | } 118 | } 119 | 120 | func TestSplitSentences(t *testing.T) { 121 | testCases := []struct { 122 | name string 123 | in string 124 | expected []string 125 | }{ 126 | { 127 | name: "empty string", 128 | in: "", 129 | expected: []string{}, 130 | }, 131 | { 132 | name: "no sentences", 133 | in: "hello world", 134 | expected: []string{"hello world"}, 135 | }, 136 | { 137 | name: "one sentence", 138 | in: "hello world.", 139 | expected: []string{"hello world."}, 140 | }, 141 | { 142 | name: "two sentences", 143 | in: "hello world. how are you today?", 144 | expected: []string{"hello world.", "how are you today?"}, 145 | }, 146 | { 147 | name: "many sentences with punctuation", 148 | in: "How are you today? Leave me alone! I'm sorry.", 149 | expected: []string{"How are you today?", "Leave me alone!", "I'm sorry."}, 150 | }, 151 | { 152 | name: "groups punctuation", 153 | in: "How are you today??? Leave me alone!!!!! I'm sorry...", 154 | expected: []string{"How are you today???", "Leave me alone!!!!!", "I'm sorry..."}, 155 | }, 156 | } 157 | 158 | for _, tc := range testCases { 159 | t.Run(tc.name, func(t *testing.T) { 160 | actual := SplitSentences(tc.in) 161 | require.Equal(t, tc.expected, actual) 162 | }) 163 | } 164 | } 165 | 166 | func TestSplitWidth(t *testing.T) { 167 | testCases := []struct { 168 | name string 169 | in string 170 | width int 171 | expected []string 172 | }{ 173 | { 174 | name: "empty string", 175 | in: "", 176 | width: 10, 177 | expected: []string{}, 178 | }, 179 | { 180 | name: "short string", 181 | in: "hello", 182 | width: 10, 183 | expected: []string{"hello"}, 184 | }, 185 | { 186 | name: "long string", 187 | in: "hello world", 188 | width: 5, 189 | expected: []string{"hello", "world"}, 190 | }, 191 | { 192 | name: "long string with spaces", 193 | in: "hello world, how are you today?", 194 | width: 7, 195 | expected: []string{"hello", "world,", "how are", "you", "today?"}, 196 | }, 197 | { 198 | name: "long string with spaces and tabs", 199 | in: "hello world,\thow are you today?", 200 | width: 7, 201 | expected: []string{"hello", "world,", "how are", "you", "today?"}, 202 | }, 203 | { 204 | name: "words longer than max width", 205 | in: "hello hippopotamus, how are you today?", 206 | width: 10, 207 | expected: []string{"hello", "hippopotam", "us, how", "are you", "today?"}, 208 | }, 209 | { 210 | name: "really long words", 211 | in: "hippopotamus girraffe elephant", 212 | width: 4, 213 | expected: []string{"hipp", "opot", "amus", "girr", "affe", "elep", "hant"}, 214 | }, 215 | } 216 | 217 | for _, tc := range testCases { 218 | t.Run(tc.name, func(t *testing.T) { 219 | actual := SplitWidth(tc.in, tc.width) 220 | require.Equal(t, tc.expected, actual) 221 | }) 222 | } 223 | } 224 | -------------------------------------------------------------------------------- /tts/tts.go: -------------------------------------------------------------------------------- 1 | package tts 2 | 3 | import ( 4 | "os/exec" 5 | ) 6 | 7 | type Config struct { 8 | Male bool 9 | AltVoice bool 10 | } 11 | 12 | func Speak(input string, c Config) error { 13 | args := append(getFlags(c), `"`+input+`"`, "-z") 14 | return exec.Command("espeak", args...).Run() 15 | } 16 | -------------------------------------------------------------------------------- /tts/tts_darwin.go: -------------------------------------------------------------------------------- 1 | package tts 2 | 3 | func getFlags(c Config) []string { 4 | flags := []string{} 5 | if c.Male { 6 | if c.AltVoice { 7 | flags = append(flags, "-v", "m7") 8 | } else { 9 | flags = append(flags, "-v", "en") 10 | } 11 | } else { 12 | if c.AltVoice { 13 | flags = append(flags, "-v", "m7") 14 | } else { 15 | flags = append(flags, "-v", "f2") 16 | } 17 | } 18 | return flags 19 | } 20 | -------------------------------------------------------------------------------- /tts/tts_linux.go: -------------------------------------------------------------------------------- 1 | package tts 2 | 3 | func getFlags(c Config) []string { 4 | flags := []string{} 5 | if c.Male { 6 | if c.AltVoice { 7 | flags = append(flags, "-v", "m7") 8 | } else { 9 | flags = append(flags, "-v", "en", "-s", "135") 10 | } 11 | } else { 12 | if c.AltVoice { 13 | flags = append(flags, "-v", "f2") 14 | } else { 15 | flags = append(flags, "-v", "f2", "-s", "135") 16 | } 17 | } 18 | return flags 19 | } 20 | --------------------------------------------------------------------------------