feat: upload audio, get a private Swedish transcript, discuss it (#33, ADR-033) #39
@@ -0,0 +1,82 @@
|
|||||||
|
// Package uploads is the use case behind "upload an audio recording" (ADR-033):
|
||||||
|
// validate the file by PROBING it, keep the transcript private, never retain the
|
||||||
|
// audio. It depends only on ports (a Prober, a store, a job Submitter) — the
|
||||||
|
// concrete ffprobe adapter, the web handler and the transcription worker are wired
|
||||||
|
// from outside.
|
||||||
|
package uploads
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits bound what an upload may be. Both are rejected synchronously, with a
|
||||||
|
// specific message, rather than by a timeout (ADR-033).
|
||||||
|
type Limits struct {
|
||||||
|
MaxBytes int64
|
||||||
|
MaxDuration time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe is what inspecting an uploaded file revealed. It is the input to Validate
|
||||||
|
// because a name or MIME type cannot say whether a container carries video: a
|
||||||
|
// Teams voice-only export is typically an .mp4 with no video stream.
|
||||||
|
type Probe struct {
|
||||||
|
Duration time.Duration
|
||||||
|
HasAudio bool
|
||||||
|
HasVideo bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reason is the machine-checkable class of a rejection.
|
||||||
|
type Reason string
|
||||||
|
|
||||||
|
const (
|
||||||
|
ReasonVideo Reason = "video"
|
||||||
|
ReasonNoAudio Reason = "no_audio"
|
||||||
|
ReasonTooLong Reason = "too_long"
|
||||||
|
ReasonTooLarge Reason = "too_large"
|
||||||
|
ReasonUnreadable Reason = "unreadable"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RejectError is a synchronous, user-presentable refusal. Message is written for
|
||||||
|
// the person who uploaded the file; Reason is for code and tests.
|
||||||
|
type RejectError struct {
|
||||||
|
Reason Reason
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RejectError) Error() string { return string(e.Reason) + ": " + e.Message }
|
||||||
|
|
||||||
|
// Validate decides whether a probed file may be transcribed. Video is checked
|
||||||
|
// first so a video file is always refused as video, whatever its length.
|
||||||
|
func Validate(p Probe, l Limits) error {
|
||||||
|
switch {
|
||||||
|
case p.HasVideo:
|
||||||
|
return &RejectError{Reason: ReasonVideo, Message: "This file contains a video track. " +
|
||||||
|
"Only audio recordings are supported for now; video upload is tracked in issue #28."}
|
||||||
|
case !p.HasAudio:
|
||||||
|
return &RejectError{Reason: ReasonNoAudio, Message: "This file has no audio track to transcribe."}
|
||||||
|
case p.Duration <= 0:
|
||||||
|
return &RejectError{Reason: ReasonUnreadable, Message: "Could not read a length from this file; it may be damaged."}
|
||||||
|
case p.Duration > l.MaxDuration:
|
||||||
|
return &RejectError{Reason: ReasonTooLong, Message: fmt.Sprintf(
|
||||||
|
"This recording is %s long; the limit is %s.", FormatDuration(p.Duration), FormatDuration(l.MaxDuration))}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatDuration renders a length for people: "1 h 42 min", "45 min", "30 s".
|
||||||
|
func FormatDuration(d time.Duration) string {
|
||||||
|
d = d.Round(time.Second)
|
||||||
|
h := int(d / time.Hour)
|
||||||
|
m := int(d % time.Hour / time.Minute)
|
||||||
|
switch {
|
||||||
|
case h > 0 && m > 0:
|
||||||
|
return fmt.Sprintf("%d h %d min", h, m)
|
||||||
|
case h > 0:
|
||||||
|
return fmt.Sprintf("%d h", h)
|
||||||
|
case m > 0:
|
||||||
|
return fmt.Sprintf("%d min", m)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d s", int(d/time.Second))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user