commit 976b6829ac983b7912e24f44b4017b4471628fce
parent 829d9b9bd266125f534f0c616175f8c7ce5fb0c2
Author: Oliver Lowe <o@olowe.co>
Date: Sun, 8 Nov 2026 15:04:31 +0800
new transcribe command
Diffstat:
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())
+}