Files

482 lines
14 KiB
Go

package editor
import (
"fmt"
"reflect"
"slices"
"aego/asset"
"aego/asset/pipeline"
"aego/core/ids"
"aego/core/mathx"
"aego/editor/core/context"
"aego/engine/i18n"
"aego/format/aetext"
"aego/modules/audio"
"aego/ui/core"
"aego/ui/widgets"
)
const (
peakCols = 256
waveH = 72
handleW = 10
maxGain = 4.0
)
var streamModes = [3][2]string{{"auto", "Auto"}, {"on", "On"}, {"off", "Off"}}
func streamLabels() [3]string {
return [3]string{i18n.Tr("i18n.audio.stream_auto", "Auto"), i18n.Tr("i18n.audio.stream_on", "On"), i18n.Tr("i18n.audio.stream_off", "Off")}
}
type clipEdit struct {
id ids.AssetID
drag int
orig int64
acc float32
start int64
end int64
loopSettings pipeline.Settings
gain float64
gainOn bool
gainSettings pipeline.Settings
loop bool
}
type peakSource interface {
Peaks(ids.AssetID, int) ([]float32, bool)
}
func formatName(f uint8) string {
if f == asset.AudioOgg {
return i18n.Tr("i18n.audio.ogg_vorbis", "Ogg Vorbis")
}
return "WAV"
}
func channels(n int) string {
if n == 1 {
return "mono"
}
return "stereo"
}
func streamMode(s pipeline.Settings) string {
if v, ok := s["stream"]; ok && v.Kind == aetext.VBool {
if v.Bool {
return "on"
}
return "off"
}
return s.String("stream", "auto")
}
type ClipView struct {
ID ids.AssetID
Path string
Format uint8
Rate int
Channels int
Frames int64
Seconds float32
LoopStart int64
LoopEnd int64
Peaks []float32
PeaksDone bool
Settings pipeline.Settings
Position float32
Playing bool
}
func DrawClip(ed *context.Editor, v ClipView) {
p := &plugin{ed: ed, clip: clipEdit{id: v.ID, drag: -1}}
p.drawClip(ed, nil, v)
}
func loopOf(v ClipView) (int64, int64) {
ls, le := v.LoopStart, v.LoopEnd
if x := v.Settings.Int("loopStart", -1); x >= 0 {
ls = int64(x)
}
if x := v.Settings.Int("loopEnd", -1); x >= 0 {
le = int64(x)
}
if le <= ls || le > v.Frames {
return 0, v.Frames
}
return ls, le
}
func (p *plugin) clipInspector(ed *context.Editor, id ids.AssetID) {
p.clipInspectorView(ed, id, false)
}
func (p *plugin) clipInspectorView(ed *context.Editor, id ids.AssetID, documentView bool) {
ui := ed.UI
pl := ed.Pipeline
var mod *audio.Module
if ed.Engine != nil {
mod = audio.ModuleOf(ed.Engine)
}
if mod == nil || pl == nil || ed.Loader == nil {
widgets.LabelDim(ui, i18n.Tr("i18n.audio.the_audio_output_is_not", "The audio output is not open"))
return
}
if p.clip.id != id {
p.clip = clipEdit{id: id, drag: -1}
}
doc, err := ed.DocumentForAsset(id)
if err != nil || doc.Metadata == nil {
widgets.LabelDim(ui, i18n.Tr("i18n.audio.the_sound_is_not_imported", "The sound is not imported yet"))
return
}
meta, err := asset.ParseSourceMeta(doc.Metadata.Code.Bytes(), doc.Metadata.Path)
if err != nil {
widgets.LabelWrapped(ui, err.Error(), true)
return
}
c, err := mod.Clip(ed.Loader, id)
if err != nil {
widgets.LabelWrapped(ui, err.Error(), true)
return
}
a := c.Art
v := ClipView{
ID: id, Format: a.Format, Rate: a.Rate, Channels: a.Channels, Frames: a.Frames, Seconds: c.Seconds,
LoopStart: a.LoopStart, LoopEnd: a.LoopEnd, Settings: meta.Settings,
}
v.Path, _ = pl.PathOf(id)
v.Peaks, v.PeaksDone = p.peaks(ed, id)
if mod.PreviewClip() == id {
v.Position, v.Playing = mod.PreviewPosition()
}
if documentView {
p.drawClipDocument(ed, mod, v)
} else {
p.drawClip(ed, mod, v)
}
}
func (p *plugin) drawClip(ed *context.Editor, mod *audio.Module, v ClipView) {
ui := ed.UI
ui.PushID("clip/" + v.ID.String())
defer ui.PopID()
if v.Path != "" {
widgets.LabelDim(ui, v.Path)
}
widgets.Labelf(ui, "%s, %d Hz, %s, %s s", formatName(v.Format), v.Rate, channels(v.Channels), seconds(v.Seconds))
p.wave(ed, v)
p.transport(ed, mod, v)
p.importFields(ed, v.Settings)
p.history(ed, v.Settings)
}
// The centre document already provides the source path and the shared
// Save/Undo/Redo toolbar. Keep the useful waveform and controls without
// rendering another copy of those actions across the full window width.
func (p *plugin) drawClipDocument(ed *context.Editor, mod *audio.Module, v ClipView) {
ui := ed.UI
ui.PushID("clip/" + v.ID.String())
defer ui.PopID()
cur := ui.Cursor()
width := cur.W
defer func() { cur.W = width }()
waveWidth, controlsWidth := clipDocumentWidths(width)
cur.W = waveWidth
widgets.Labelf(ui, "%s, %d Hz, %s, %s s", formatName(v.Format), v.Rate, channels(v.Channels), seconds(v.Seconds))
p.wave(ed, v)
cur.W = controlsWidth
p.transport(ed, mod, v)
p.importFields(ed, v.Settings)
p.metadataConflict(ed)
}
func clipDocumentWidths(width float32) (wave, controls float32) {
return min(max(width, 0), 900), min(max(width, 0), 440)
}
func (p *plugin) peaks(ed *context.Editor, id ids.AssetID) ([]float32, bool) {
if src, ok := ed.Thumbs.(peakSource); ok {
return src.Peaks(id, peakCols)
}
return nil, true
}
func (p *plugin) wave(ed *context.Editor, v ClipView) {
ui := ed.UI
th := &ui.Theme
r := ui.Cursor().Next(waveH)
ui.Box(r, th.Radius.Button, th.Background, th.Border)
k := r.W / float32(max(v.Frames, 1))
hid := [2]core.ID{core.IDSeq(ui.ID("loop"), 0), core.IDSeq(ui.ID("loop"), 1)}
if p.clip.drag >= 0 && ui.Active() != hid[p.clip.drag] {
p.commitLoop(ed, v)
}
ls, le := loopOf(v)
if p.clip.drag >= 0 {
ls, le = p.clip.start, p.clip.end
}
band := th.Accent
band.A = 0.16
xs := [2]float32{r.X + float32(ls)*k, r.X + float32(le)*k}
if v.Frames > 0 {
ui.Rect(mathx.Rect{X: xs[0], Y: r.Y, W: xs[1] - xs[0], H: r.H}, band)
}
mid := r.Y + r.H/2
if len(v.Peaks) > 0 {
w := r.W / float32(len(v.Peaks))
for i, x := range v.Peaks {
h := max(min(x, 1)*(r.H/2-3), 0.5)
ui.Rect(mathx.Rect{X: r.X + float32(i)*w, Y: mid - h, W: max(w, 1), H: 2 * h}, th.AccentDim)
}
} else {
label := i18n.Tr("i18n.audio.reading_the_waveform", "Reading the waveform")
if v.PeaksDone {
label = i18n.Tr("i18n.audio.no_waveform", "No waveform")
}
ui.TextIn(r, label, th.TextDim, core.AlignCenter)
}
if v.Frames <= 0 || r.W <= 0 {
return
}
for i, hx := range xs {
hr := mathx.Rect{X: hx - handleW/2, Y: r.Y, W: handleW, H: r.H}
if d, ok := ui.Drag(hid[i], hr); ok {
if p.clip.drag != i {
p.clip.drag, p.clip.start, p.clip.end, p.clip.acc = i, ls, le, 0
p.clip.loopSettings = v.Settings.Clone()
p.clip.orig = ls
if i == 1 {
p.clip.orig = le
}
}
p.clip.acc += d.X
x := p.clip.orig + int64(p.clip.acc/k)
if i == 0 {
p.clip.start = min(max(x, 0), p.clip.end-1)
} else {
p.clip.end = min(max(x, p.clip.start+1), v.Frames)
}
}
col := th.AccentDim
if ui.Active() == hid[i] || ui.Hot() == hid[i] {
col = th.Accent
}
ui.Rect(mathx.Rect{X: hx - 1, Y: r.Y, W: 2, H: r.H}, col)
ui.Rect(mathx.Rect{X: hx - handleW/2, Y: r.Y, W: handleW, H: 6}, col)
}
if v.Playing && v.Seconds > 0 {
px := r.X + min(max(v.Position/v.Seconds, 0), 1)*r.W
ui.Rect(mathx.Rect{X: px, Y: r.Y, W: 1, H: r.H}, th.Text)
}
}
func (p *plugin) commitLoop(ed *context.Editor, v ClipView) {
start, end := p.clip.start, p.clip.end
before := p.clip.loopSettings
p.clip.drag = -1
p.clip.loopSettings = nil
if ls, le := loopOf(v); ls == start && le == end {
return
}
if before == nil {
// There was no captured handle gesture; never infer an old preimage.
return
}
n := before.Clone()
n["loopStart"], n["loopEnd"] = aetext.Int(start), aetext.Int(end)
p.write(ed, before, n)
}
func (p *plugin) write(ed *context.Editor, old, n pipeline.Settings) {
d, err := ed.DocumentForAsset(p.clip.id)
if err != nil {
p.notify(err)
return
}
if d.Metadata == nil || d.Metadata.ReadOnly || d.Metadata.Conflict {
p.notify(fmt.Errorf("resolve the audio metadata conflict before editing"))
return
}
m, err := asset.ParseSourceMeta(d.Metadata.Code.Bytes(), d.Metadata.Path)
if err != nil {
p.notify(err)
return
}
changed, err := mergeClipSettings(m.Settings, old, n)
if err != nil {
p.notify(err)
return
}
if changed {
// Keep source identity, frames and unknown metadata. All changed fields
// are applied as one step in the existing shared metadata history.
d.Metadata.Code.ReplaceSnapshot(asset.MarshalSourceMeta(m), false)
}
}
// The Inspector and a centre tab may edit the same metadata in one frame.
// Change only fields touched by this control and reject a stale write to the
// same field. Validate all fields before mutating the parsed snapshot.
func mergeClipSettings(current, before, after pipeline.Settings) (bool, error) {
keys := []string{"loopStart", "loopEnd", "stream", "gain", "bus"}
var changed []string
for _, key := range keys {
old, oldOK := before[key]
next, nextOK := after[key]
if equalClipSetting(old, oldOK, next, nextOK) {
continue
}
now, nowOK := current[key]
if !equalClipSetting(old, oldOK, now, nowOK) {
return false, fmt.Errorf("%s", i18n.Tr("editor.audio.setting_changed", "This audio setting changed in another view. Review it before editing again."))
}
changed = append(changed, key)
}
if slices.Contains(changed, "loopStart") || slices.Contains(changed, "loopEnd") {
// The handles are constrained as a pair. A concurrently moved opposite
// handle can invalidate this drag even if its own field was not edited.
for _, key := range []string{"loopStart", "loopEnd"} {
old, oldOK := before[key]
now, nowOK := current[key]
if !equalClipSetting(old, oldOK, now, nowOK) {
return false, fmt.Errorf("%s", i18n.Tr("editor.audio.setting_changed", "This audio setting changed in another view. Review it before editing again."))
}
}
}
for _, key := range changed {
if value, ok := after[key]; ok {
current[key] = value.Clone()
} else {
delete(current, key)
}
}
return len(changed) != 0, nil
}
func equalClipSetting(a aetext.Value, aOK bool, b aetext.Value, bOK bool) bool {
if aOK != bOK {
return false
}
if !aOK {
return true
}
// Serialization may parse 1.0 as integer 1. Limit cross-kind comparison
// to exactly representable integers rather than rounding large frame values.
if a.Kind == aetext.VInt && b.Kind == aetext.VFloat {
return a.Int >= -(1<<53) && a.Int <= 1<<53 && float64(a.Int) == b.Float
}
if a.Kind == aetext.VFloat && b.Kind == aetext.VInt {
return b.Int >= -(1<<53) && b.Int <= 1<<53 && a.Float == float64(b.Int)
}
if a.Kind == aetext.VFloat && b.Kind == aetext.VFloat {
return a.Float == b.Float
}
return reflect.DeepEqual(a, b)
}
func (p *plugin) transport(ed *context.Editor, mod *audio.Module, v ClipView) {
ui := ed.UI
row := ui.Cursor().Next(0)
w := max(0, (row.W-8)/3)
at := func(i int) mathx.Rect {
return mathx.Rect{X: row.X + float32(i)*(w+4), Y: row.Y, W: w, H: row.H}
}
if widgets.ButtonRect(ui, ui.ID("play"), at(0), i18n.Tr("i18n.audio.play", "Play")) && mod != nil {
owner := p
if p.auditionOwner != nil {
owner = p.auditionOwner
}
owner.aud = audition{}
p.notify(mod.Preview(ed.Loader, v.ID, p.clip.loop))
}
if widgets.ButtonRect(ui, ui.ID("stop"), at(1), i18n.Tr("i18n.audio.stop", "Stop")) && mod != nil {
mod.StopPreview()
}
widgets.ToggleButton(ui, ui.ID("looping"), at(2), i18n.Tr("i18n.audio.loop", "Loop"), &p.clip.loop)
widgets.LabelDim(ui, i18n.Tr("i18n.audio.position_s_of_seconds_s", "{position} s of {seconds} s", i18n.Str("position", seconds(v.Position)), i18n.Str("seconds", seconds(v.Seconds))))
}
func (p *plugin) importFields(ed *context.Editor, s pipeline.Settings) {
ui := ed.UI
widgets.LabelDim(ui, i18n.Tr("i18n.audio.import", "Import"))
cur := streamMode(s)
r := widgets.Field(ui, i18n.Tr("i18n.audio.stream", "Stream"))
w := max(0, (r.W-8)/3)
for i, m := range streamModes {
on := cur == m[0]
br := mathx.Rect{X: r.X + float32(i)*(w+4), Y: r.Y, W: w, H: r.H}
if widgets.ToggleButton(ui, core.IDSeq(ui.ID("stream"), i), br, streamLabels()[i], &on) && cur != m[0] {
n := s.Clone()
n["stream"] = aetext.Ident(m[0])
p.write(ed, s, n)
}
}
g := s.Float("gain", 1)
if p.clip.gainOn {
g = p.clip.gain
}
e := widgets.DragNumber(ui, ui.ID("gain"), widgets.Field(ui, i18n.Tr("i18n.audio.gain", "Gain")), &g, widgets.NumberOpts{Speed: 0.01, Prec: 2})
if e.Changed {
if !p.clip.gainOn {
p.clip.gainSettings = s.Clone()
}
p.clip.gain, p.clip.gainOn = min(max(g, 0), maxGain), true
}
if e.End && p.clip.gainOn {
p.clip.gainOn = false
before := p.clip.gainSettings
p.clip.gainSettings = nil
if before != nil && p.clip.gain != s.Float("gain", 1) {
n := before.Clone()
n["gain"] = aetext.Float64(p.clip.gain)
p.write(ed, before, n)
}
}
names := audio.Buses[1:]
cur = s.String("bus", "sfx")
sel := slices.Index(names, cur)
if widgets.Combo(ui, ui.ID("bus"), widgets.Field(ui, i18n.Tr("i18n.audio.bus", "Bus")), &sel, names, false) && sel >= 0 && names[sel] != cur {
n := s.Clone()
n["bus"] = aetext.Ident(names[sel])
p.write(ed, s, n)
}
}
func (p *plugin) history(ed *context.Editor, s pipeline.Settings) {
ui := ed.UI
d, err := ed.DocumentForAsset(p.clip.id)
if err != nil || d.Metadata == nil {
return
}
undo, redo := twoButtons(ui)
if widgets.ButtonRect(ui, ui.ID("undo"), undo, i18n.Tr("i18n.audio.undo", "Undo")) {
p.notify(ed.UndoAsset(d))
}
if widgets.ButtonRect(ui, ui.ID("redo"), redo, i18n.Tr("i18n.audio.redo", "Redo")) {
p.notify(ed.RedoAsset(d))
}
p.metadataConflict(ed)
if d.Dirty() && widgets.Button(ui, i18n.Tr("editor.document.save_metadata", "Save Metadata")) {
p.notify(ed.SaveAsset(d))
}
}
func (p *plugin) metadataConflict(ed *context.Editor) {
ui := ed.UI
d, err := ed.DocumentForAsset(p.clip.id)
if err != nil || d.Metadata == nil {
return
}
if d.Metadata.Conflict {
widgets.LabelWrapped(ui, i18n.Tr("editor.document.metadata_conflict", "Metadata changed on disk. Reload it or explicitly keep your changes before saving."), true)
if widgets.Button(ui, i18n.Tr("editor.scripteditor.reload", "Reload")) {
p.notify(ed.Docs.ReloadScript(d.Metadata))
}
if widgets.Button(ui, i18n.Tr("editor.scripteditor.keep_mine", "Keep mine")) {
ed.Docs.KeepScript(d.Metadata)
}
}
}