├── .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 | [](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 |
--------------------------------------------------------------------------------