x

Programs, configuration and documentation that don't fit anywhere else
Log | Files | Refs | README | LICENSE

commit 976b6829ac983b7912e24f44b4017b4471628fce
parent 829d9b9bd266125f534f0c616175f8c7ce5fb0c2
Author: Oliver Lowe <o@olowe.co>
Date:   Sun,  8 Nov 2026 15:04:31 +0800

new transcribe command

Diffstat:
Asrc/internal/aiconf/aiconf.go | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/llm/config.go | 49-------------------------------------------------
Msrc/llm/llm.go | 17+++++++++++++----
Msrc/openai/openai.go | 9+++++----
Asrc/openai/transcription.go | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/transcribe/transcribe.go | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 227 insertions(+), 57 deletions(-)

diff --git a/src/internal/aiconf/aiconf.go b/src/internal/aiconf/aiconf.go @@ -0,0 +1,49 @@ +package aiconf + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +type Config struct { + BaseURL string + Token string + DefaultModel string +} + +func ReadConfig(name string) (*Config, error) { + f, err := os.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + + var conf Config + sc := bufio.NewScanner(f) + for sc.Scan() { + if strings.HasPrefix(sc.Text(), "#") { + continue + } else if sc.Text() == "" { + continue + } + + k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), " ") + if !ok { + return nil, fmt.Errorf("key %q: expected space after key", k) + } + v = strings.TrimSpace(v) + switch k { + case "token": + conf.Token = v + case "url": + conf.BaseURL = v + case "model": + conf.DefaultModel = v + default: + return nil, fmt.Errorf("unknown configuration key %q", k) + } + } + return &conf, sc.Err() +} diff --git a/src/llm/config.go b/src/llm/config.go @@ -1,49 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "os" - "strings" -) - -type Config struct { - BaseURL string - Token string - DefaultModel string -} - -func readConfig(name string) (*Config, error) { - f, err := os.Open(name) - if err != nil { - return nil, err - } - defer f.Close() - - var conf Config - sc := bufio.NewScanner(f) - for sc.Scan() { - if strings.HasPrefix(sc.Text(), "#") { - continue - } else if sc.Text() == "" { - continue - } - - k, v, ok := strings.Cut(strings.TrimSpace(sc.Text()), " ") - if !ok { - return nil, fmt.Errorf("key %q: expected space after key", k) - } - v = strings.TrimSpace(v) - switch k { - case "token": - conf.Token = v - case "url": - conf.BaseURL = v - case "model": - conf.DefaultModel = v - default: - return nil, fmt.Errorf("unknown configuration key %q", k) - } - } - return &conf, sc.Err() -} diff --git a/src/llm/llm.go b/src/llm/llm.go @@ -12,6 +12,7 @@ import ( "os" "path" + "olowe.co/x/internal/aiconf" "olowe.co/x/openai" ) @@ -50,7 +51,7 @@ func main() { if err != nil { log.Fatal(err) } - config, err := readConfig(path.Join(confDir, "openai")) + config, err := aiconf.ReadConfig(path.Join(confDir, "openai")) if err != nil { log.Fatalf("read configuration: %v", err) } @@ -65,7 +66,10 @@ func main() { chat := openai.Chat{Model: *model} if *sysPrompt != "" { chat.Messages = []openai.Message{ - {openai.RoleSystem, *sysPrompt}, + { + Role: openai.RoleSystem, + Content: *sysPrompt, + }, } } buf := &bytes.Buffer{} @@ -74,7 +78,9 @@ func main() { if err != nil { log.Fatalln("construct prompt:", err) } - msg := openai.Message{openai.RoleUser, buf.String()} + msg := openai.Message{ + Role: openai.RoleUser, Content: buf.String(), + } chat.Messages = append(chat.Messages, msg) reply, err := client.Complete(&chat) if err != nil { @@ -90,7 +96,10 @@ func main() { } for sc.Scan() { if sc.Text() == "." { - msg := openai.Message{openai.RoleUser, buf.String()} + msg := openai.Message{ + Role: openai.RoleUser, + Content: buf.String(), + } chat.Messages = append(chat.Messages, msg) reply, err := client.Complete(&chat) if err != nil { diff --git a/src/openai/openai.go b/src/openai/openai.go @@ -90,8 +90,9 @@ func (c *Client) do(req *http.Request) (*http.Response, error) { if c.Token != "" { req.Header.Set("Authorization", "Bearer "+c.Token) } - req.Header.Set("Accept", "application/json") - if req.Body != nil { + // req.Header.Set("Accept", "application/json") + // assume we're sending JSON if not already set since most requests mandate it + if req.Body != nil && req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", "application/json") } return c.Do(req) @@ -108,7 +109,7 @@ func (c *Client) Complete(chat *Chat) (*Message, error) { if err != nil { return nil, fmt.Errorf("encode messages: %w", err) } - u := c.BaseURL + "/v1/chat/completions" + u := c.BaseURL + "/chat/completions" req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(b)) if err != nil { return nil, err @@ -141,7 +142,7 @@ func (c *Client) Complete(chat *Chat) (*Message, error) { } func (c *Client) Models() ([]Model, error) { - u := c.BaseURL + "/v1/models" + u := c.BaseURL + "/models" req, err := http.NewRequest(http.MethodGet, u, nil) if err != nil { return nil, err diff --git a/src/openai/transcription.go b/src/openai/transcription.go @@ -0,0 +1,108 @@ +package openai + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime/multipart" + "net/http" + "os" + "strconv" + "strings" +) + +type Transcription struct { + Language string + Text string + Segments []struct { + Text string + Start float32 + End float32 + } +} + +func (t Transcription) SubRip() string { + b := &strings.Builder{} + for i := 0; i < len(t.Segments); i++ { + fmt.Fprintln(b, strconv.Itoa(i+1)) + fmt.Fprintf(b, "%s --> %s\n", secondsToTimecode(t.Segments[i].Start), secondsToTimecode(t.Segments[i].End)) + // TODO: trim space? + fmt.Fprintln(b, t.Segments[i].Text) + fmt.Fprintln(b) + } + return b.String() +} + +// TODO: use time.Duration with rounding to milliseconds instead. +func secondsToTimecode(seconds float32) string { + totalMs := int(seconds * 1000) + hours := totalMs / 3600000 + totalMs %= 3600000 + minutes := totalMs / 60000 + totalMs %= 60000 + secondsPart := totalMs / 1000 + ms := totalMs % 1000 + + // Format with leading zeros + return fmt.Sprintf("%02d:%02d:%02d,%03d", hours, minutes, secondsPart, ms) +} + +// language must be in [ISO-639-1] format such as "en" for English. +// +// [ISO-639-1]: https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes +func (c *Client) Transcribe(r io.Reader, language, model string, timestamps bool) (*Transcription, error) { + buf := &bytes.Buffer{} + w := multipart.NewWriter(buf) + if err := w.WriteField("model", model); err != nil { + return nil, err + } + if err := w.WriteField("language", language); err != nil { + return nil, err + } + if timestamps { + if err := w.WriteField("timestamp_granularities", "segment"); err != nil { + return nil, err + } + } + field, err := w.CreateFormFile("file", "sample.mp3") + if err != nil { + return nil, err + } + if _, err = io.Copy(field, r); err != nil { + return nil, fmt.Errorf("copy audio to request body: %w", err) + } + if err := w.Close(); err != nil { + return nil, err + } + + req, err := http.NewRequest(http.MethodPost, c.BaseURL+"/audio/transcriptions", buf) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", w.FormDataContentType()) + resp, err := c.do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("unauthorised") + } else if resp.StatusCode >= 400 && resp.StatusCode <= 499 { + var aerr apiError + + io.Copy(os.Stderr, resp.Body) + return nil, fmt.Errorf(resp.Status) + + if err := json.NewDecoder(resp.Body).Decode(&aerr); err != nil { + return nil, fmt.Errorf(resp.Status) + } + return nil, aerr + } + + var tscript Transcription + if err := json.NewDecoder(resp.Body).Decode(&tscript); err != nil { + return nil, fmt.Errorf("decode transciption response: %w", err) + } + return &tscript, nil +} diff --git a/src/transcribe/transcribe.go b/src/transcribe/transcribe.go @@ -0,0 +1,52 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + + "olowe.co/x/internal/aiconf" + "olowe.co/x/openai" +) + +var model = flag.String("m", "", "model") +var language = flag.String("l", "en", "language") +var timestamps = flag.Bool("t", false, "generate timestamps") + +func init() { + log.SetFlags(0) + log.SetPrefix("transcribe: ") + flag.Parse() +} + +func main() { + if len(os.Args) != 2 { + fmt.Fprintln(os.Stderr, "usage: transcribe file") + os.Exit(2) + } + + config, err := aiconf.ReadConfig("/home/otl/.config/openai") + if err != nil { + log.Fatal(err) + } + if *model == "" { + *model = config.DefaultModel + } + client := &openai.Client{ + http.DefaultClient, + config.Token, + config.BaseURL, + } + + f, err := os.Open(os.Args[1]) + if err != nil { + log.Fatal(err) + } + tscript, err := client.Transcribe(f, *language, *model, *timestamps) + if err != nil { + log.Fatal(err) + } + os.Stdout.WriteString(tscript.SubRip()) +}