package pipeline
import (
"crypto/sha1"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/erf"
"gitea.westgate.pw/ShadowsOverWestgate/sow-tools/internal/project"
)
const (
maxMusicStemLen = 16
musicCreditsHeader = "# NWN Music Pack Credits\n\nOriginal rights remain with the respective composers and licensors.\n\n## Processed Files\n\n| Artist | Title | Output File | Original File | Album | Date | License / Copyright | Notes |\n|---|---|---|---|---|---|---|---|\n"
)
var (
musicConvertExtensions = map[string]struct{}{
".mp3": {},
".ogg": {},
}
musicPassthroughExtensions = map[string]struct{}{
".bmu": {},
".wav": {},
}
musicDropWords = map[string]struct{}{
"mp3": {}, "wav": {}, "flac": {}, "ogg": {}, "m4a": {},
"music": {}, "track": {}, "audio": {}, "song": {}, "soundtrack": {},
"final": {}, "version": {}, "ver": {}, "v": {}, "mix": {}, "master": {}, "remaster": {},
"free": {}, "royalty": {}, "copyright": {}, "background": {}, "bgm": {},
"loop": {}, "loops": {}, "loopable": {}, "seamless": {},
"official": {}, "download": {}, "preview": {},
"the": {}, "a": {}, "an": {}, "and": {}, "of": {}, "to": {}, "in": {}, "for": {}, "with": {}, "by": {},
}
musicBracketRE = regexp.MustCompile(`\[[^\]]*\]|\([^)]*\)|\{[^}]*\}`)
musicSplitRE = regexp.MustCompile(`[^A-Za-z0-9]+`)
musicYearRE = regexp.MustCompile(`^\d{4,}$`)
musicVowelRE = regexp.MustCompile(`[aeiou]`)
)
type preparedMusicAssets struct {
Generated []assetResource
SkipSourceRel map[string]struct{}
Artifacts []string
}
type musicMetadata struct {
Title string
Artist string
Album string
Date string
Rights string
Notes string
Comment string
Copyright string
License string
}
type creditsEntry struct {
Artist string `json:"artist"`
Title string `json:"title"`
OutputFile string `json:"output_file"`
OriginalFile string `json:"original_file"`
Album string `json:"album"`
Date string `json:"date"`
Rights string `json:"rights"`
Notes string `json:"notes"`
}
type creditsSource struct {
Path string `json:"path"`
Kind string `json:"kind"`
Entries []creditsEntry `json:"entries"`
}
type creditsInventory struct {
Sources []creditsSource `json:"sources"`
}
type creditsOverlay struct {
ByOriginal map[string]creditsEntry
ByOutput map[string]creditsEntry
Entries []creditsEntry
}
func prepareMusicAssets(p *project.Project) (*preparedMusicAssets, error) {
usedNames := make(map[string]struct{})
dirSources := make(map[string][]string)
for _, rel := range p.Inventory.AssetFiles {
rel = filepath.ToSlash(rel)
ext := strings.ToLower(filepath.Ext(rel))
name := strings.ToLower(strings.TrimSuffix(filepath.Base(rel), filepath.Ext(rel)))
if _, convertible := musicConvertExtensions[ext]; convertible && isMusicAssetPath(rel) {
dir := filepath.ToSlash(filepath.Dir(rel))
dirSources[dir] = append(dirSources[dir], rel)
continue
}
if name != "" {
usedNames[name] = struct{}{}
}
}
result := &preparedMusicAssets{
SkipSourceRel: make(map[string]struct{}),
}
if len(dirSources) == 0 {
artifactPaths, err := writeCreditsArtifacts(p, nil)
if err != nil {
return nil, err
}
result.Artifacts = artifactPaths
return result, nil
}
dirs := make([]string, 0, len(dirSources))
for dir := range dirSources {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
generatedByDir := make(map[string][]creditsEntry)
stageRoot := filepath.Join(p.BuildDir(), ".cache", "music")
ffmpegPath, err := resolveFFmpegBinary()
if err != nil {
return nil, err
}
ffprobePath, err := resolveFFprobeBinary()
if err != nil {
return nil, err
}
for _, dir := range dirs {
sources := append([]string(nil), dirSources[dir]...)
sort.Strings(sources)
prefix := musicPrefixForDir(p, dir)
overlayPath := filepath.Join(p.AssetsDir(), filepath.FromSlash(dir), "CREDITS.md")
overlay, err := parseCreditsOverlay(overlayPath)
if err != nil {
return nil, fmt.Errorf("parse credits overlay %s: %w", overlayPath, err)
}
entries := make([]creditsEntry, 0, len(sources))
for _, rel := range sources {
base := filepath.Base(rel)
manual := overlay.match(base, "")
outputFile := ""
switch {
case manual != nil && strings.TrimSpace(manual.OutputFile) != "":
outputFile = strings.ToLower(strings.TrimSpace(manual.OutputFile))
if err := validateManualOutputFile(outputFile); err != nil {
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
}
if err := reserveMusicName(strings.TrimSuffix(outputFile, filepath.Ext(outputFile)), usedNames); err != nil {
return nil, fmt.Errorf("manual credits output for %s: %w", rel, err)
}
default:
stem, err := generateMusicStem(base, prefix, usedNames)
if err != nil {
return nil, fmt.Errorf("generate music name for %s: %w", rel, err)
}
outputFile = stem + ".bmu"
}
outputRel := filepath.ToSlash(filepath.Join(dir, outputFile))
metadata, err := probeMusicMetadata(ffprobePath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)))
if err != nil {
return nil, fmt.Errorf("probe music metadata for %s: %w", rel, err)
}
entry := creditsEntry{
Artist: metadata.Artist,
Title: firstNonEmpty(metadata.Title, strings.TrimSuffix(base, filepath.Ext(base))),
OutputFile: outputFile,
OriginalFile: base,
Album: metadata.Album,
Date: metadata.Date,
Rights: firstNonEmpty(metadata.Rights, joinNonEmpty("; ", metadata.License, metadata.Copyright)),
Notes: firstNonEmpty(metadata.Notes, metadata.Comment),
}
applyCreditsOverlay(&entry, manual)
stagePath := filepath.Join(stageRoot, filepath.FromSlash(outputRel))
if err := os.MkdirAll(filepath.Dir(stagePath), 0o755); err != nil {
return nil, fmt.Errorf("create music stage dir: %w", err)
}
if err := convertMusicSource(ffmpegPath, filepath.Join(p.AssetsDir(), filepath.FromSlash(rel)), stagePath); err != nil {
return nil, fmt.Errorf("convert music %s: %w", rel, err)
}
resourceInfo, err := assetResourceFromPath(stagePath, outputRel, lfsAssetInfo{}, false)
if err != nil {
return nil, fmt.Errorf("load converted music %s: %w", outputRel, err)
}
info, err := os.Stat(stagePath)
if err != nil {
return nil, fmt.Errorf("stat converted music %s: %w", stagePath, err)
}
result.Generated = append(result.Generated, assetResource{
Rel: outputRel,
Resource: resourceInfo.Resource,
Size: erf.ArchiveSize([]erf.Resource{resourceInfo.Resource}),
CreatedAt: info.ModTime(),
ContentID: resourceInfo.ContentID,
})
result.SkipSourceRel[rel] = struct{}{}
entries = append(entries, entry)
}
if err := validateOverlayEntries(dir, overlay, entries); err != nil {
return nil, err
}
generatedByDir[dir] = entries
}
artifactPaths, err := writeCreditsArtifacts(p, generatedByDir)
if err != nil {
return nil, err
}
result.Artifacts = artifactPaths
sort.Slice(result.Generated, func(i, j int) bool {
return compareResourceKeys(result.Generated[i].Resource, result.Generated[j].Resource) < 0
})
return result, nil
}
func writeCreditsArtifacts(p *project.Project, generatedByDir map[string][]creditsEntry) ([]string, error) {
creditsRoot := filepath.Join(p.BuildDir(), "credits")
if err := os.MkdirAll(creditsRoot, 0o755); err != nil {
return nil, fmt.Errorf("create credits dir: %w", err)
}
sources := make([]creditsSource, 0)
artifacts := make([]string, 0)
if generatedByDir != nil {
dirs := make([]string, 0, len(generatedByDir))
for dir := range generatedByDir {
dirs = append(dirs, dir)
}
sort.Strings(dirs)
for _, dir := range dirs {
entries := append([]creditsEntry(nil), generatedByDir[dir]...)
sort.Slice(entries, func(i, j int) bool {
if strings.ToLower(entries[i].Artist) != strings.ToLower(entries[j].Artist) {
return strings.ToLower(entries[i].Artist) < strings.ToLower(entries[j].Artist)
}
if strings.ToLower(entries[i].Title) != strings.ToLower(entries[j].Title) {
return strings.ToLower(entries[i].Title) < strings.ToLower(entries[j].Title)
}
return strings.ToLower(entries[i].OutputFile) < strings.ToLower(entries[j].OutputFile)
})
outputPath := filepath.Join(creditsRoot, filepath.FromSlash(dir), "CREDITS.md")
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return nil, fmt.Errorf("create generated credits dir: %w", err)
}
if err := os.WriteFile(outputPath, []byte(renderCreditsMarkdown(entries)), 0o644); err != nil {
return nil, fmt.Errorf("write generated credits %s: %w", outputPath, err)
}
artifacts = append(artifacts, outputPath)
sources = append(sources, creditsSource{
Path: filepath.ToSlash(outputPath),
Kind: "generated_music",
Entries: entries,
})
}
}
err := filepath.WalkDir(p.AssetsDir(), func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
if strings.ToLower(d.Name()) != "credits.md" {
return nil
}
entries, err := parseCreditsMarkdown(path)
if err != nil {
return fmt.Errorf("parse credits %s: %w", path, err)
}
rel, err := filepath.Rel(p.Root, path)
if err != nil {
return err
}
sources = append(sources, creditsSource{
Path: filepath.ToSlash(rel),
Kind: "authored",
Entries: entries,
})
return nil
})
if err != nil {
return nil, err
}
sort.Slice(sources, func(i, j int) bool { return sources[i].Path < sources[j].Path })
inventoryPath := filepath.Join(creditsRoot, "credits.json")
payload, err := json.MarshalIndent(creditsInventory{Sources: sources}, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal credits inventory: %w", err)
}
payload = append(payload, '\n')
if err := os.WriteFile(inventoryPath, payload, 0o644); err != nil {
return nil, fmt.Errorf("write credits inventory: %w", err)
}
artifacts = append(artifacts, inventoryPath)
return artifacts, nil
}
func parseCreditsOverlay(path string) (creditsOverlay, error) {
entries, err := parseCreditsMarkdown(path)
if err != nil {
return creditsOverlay{}, err
}
overlay := creditsOverlay{
ByOriginal: make(map[string]creditsEntry),
ByOutput: make(map[string]creditsEntry),
Entries: entries,
}
for _, entry := range entries {
if entry.OriginalFile != "" {
key := strings.ToLower(strings.TrimSpace(entry.OriginalFile))
if _, exists := overlay.ByOriginal[key]; exists {
return creditsOverlay{}, fmt.Errorf("duplicate manual credits row for original file %s", entry.OriginalFile)
}
overlay.ByOriginal[key] = entry
}
if entry.OutputFile != "" {
key := strings.ToLower(strings.TrimSpace(entry.OutputFile))
if _, exists := overlay.ByOutput[key]; exists {
return creditsOverlay{}, fmt.Errorf("duplicate manual credits row for output file %s", entry.OutputFile)
}
overlay.ByOutput[key] = entry
}
}
return overlay, nil
}
func (o creditsOverlay) match(originalFile, outputFile string) *creditsEntry {
if entry, ok := o.ByOutput[strings.ToLower(strings.TrimSpace(outputFile))]; ok {
entryCopy := entry
return &entryCopy
}
if entry, ok := o.ByOriginal[strings.ToLower(strings.TrimSpace(originalFile))]; ok {
entryCopy := entry
return &entryCopy
}
return nil
}
func validateOverlayEntries(dir string, overlay creditsOverlay, generated []creditsEntry) error {
if len(overlay.Entries) == 0 {
return nil
}
validOriginal := make(map[string]struct{}, len(generated))
validOutput := make(map[string]struct{}, len(generated))
for _, entry := range generated {
validOriginal[strings.ToLower(entry.OriginalFile)] = struct{}{}
validOutput[strings.ToLower(entry.OutputFile)] = struct{}{}
}
for _, entry := range overlay.Entries {
if entry.OriginalFile != "" {
if _, ok := validOriginal[strings.ToLower(entry.OriginalFile)]; !ok {
return fmt.Errorf("manual credits entry in %s references unknown original file %s", dir, entry.OriginalFile)
}
}
if entry.OutputFile != "" {
if _, ok := validOutput[strings.ToLower(entry.OutputFile)]; !ok {
return fmt.Errorf("manual credits entry in %s references unknown output file %s", dir, entry.OutputFile)
}
}
}
return nil
}
func parseCreditsMarkdown(path string) ([]creditsEntry, error) {
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
lines := strings.Split(strings.ReplaceAll(string(raw), "\r\n", "\n"), "\n")
header := []string(nil)
entries := make([]creditsEntry, 0)
for _, line := range lines {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "|") {
continue
}
cells := parseMarkdownTableRow(line)
if len(cells) == 0 {
continue
}
if header == nil {
header = make([]string, len(cells))
for i, cell := range cells {
header[i] = normalizeCreditsHeader(cell)
}
continue
}
if isMarkdownSeparatorRow(cells) {
continue
}
entry := creditsEntry{}
for i, key := range header {
if i >= len(cells) {
continue
}
value := cleanCreditsCell(cells[i])
switch key {
case "artist":
entry.Artist = value
case "title":
entry.Title = value
case "output_file":
entry.OutputFile = value
case "original_file":
entry.OriginalFile = value
case "album":
entry.Album = value
case "date":
entry.Date = value
case "rights":
entry.Rights = value
case "notes":
entry.Notes = value
}
}
if entry.Artist == "" && entry.Title == "" && entry.OutputFile == "" && entry.OriginalFile == "" {
continue
}
entries = append(entries, entry)
}
return entries, nil
}
func parseMarkdownTableRow(line string) []string {
trimmed := strings.TrimSpace(line)
trimmed = strings.TrimPrefix(trimmed, "|")
trimmed = strings.TrimSuffix(trimmed, "|")
parts := strings.Split(trimmed, "|")
cells := make([]string, 0, len(parts))
for _, part := range parts {
cells = append(cells, strings.TrimSpace(strings.ReplaceAll(part, `\|`, "|")))
}
return cells
}
func isMarkdownSeparatorRow(cells []string) bool {
if len(cells) == 0 {
return false
}
for _, cell := range cells {
cell = strings.TrimSpace(cell)
cell = strings.ReplaceAll(cell, "-", "")
cell = strings.ReplaceAll(cell, ":", "")
if cell != "" {
return false
}
}
return true
}
func normalizeCreditsHeader(value string) string {
switch strings.ToLower(cleanCreditsCell(value)) {
case "artist":
return "artist"
case "title":
return "title"
case "output file":
return "output_file"
case "original file":
return "original_file"
case "album":
return "album"
case "date":
return "date"
case "license / copyright":
return "rights"
case "notes":
return "notes"
default:
return ""
}
}
func cleanCreditsCell(value string) string {
value = strings.TrimSpace(value)
value = strings.Trim(value, "`")
value = strings.ReplaceAll(value, "
", "\n")
return strings.TrimSpace(value)
}
func renderCreditsMarkdown(entries []creditsEntry) string {
var builder strings.Builder
builder.WriteString(musicCreditsHeader)
for _, entry := range entries {
builder.WriteString("| ")
builder.WriteString(escapeMarkdownCell(entry.Artist))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(entry.Title))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(wrapCodeCell(entry.OutputFile)))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(wrapCodeCell(entry.OriginalFile)))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(entry.Album))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(entry.Date))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(entry.Rights))
builder.WriteString(" | ")
builder.WriteString(escapeMarkdownCell(entry.Notes))
builder.WriteString(" |\n")
}
return builder.String()
}
func wrapCodeCell(value string) string {
if strings.TrimSpace(value) == "" {
return ""
}
return "`" + value + "`"
}
func escapeMarkdownCell(value string) string {
value = strings.ReplaceAll(value, `\`, `\\`)
value = strings.ReplaceAll(value, "|", `\|`)
value = strings.ReplaceAll(value, "\n", "
")
return value
}
func applyCreditsOverlay(entry *creditsEntry, overlay *creditsEntry) {
if overlay == nil {
return
}
entry.Artist = firstNonEmpty(overlay.Artist, entry.Artist)
entry.Title = firstNonEmpty(overlay.Title, entry.Title)
entry.Album = firstNonEmpty(overlay.Album, entry.Album)
entry.Date = firstNonEmpty(overlay.Date, entry.Date)
entry.Rights = firstNonEmpty(overlay.Rights, entry.Rights)
entry.Notes = firstNonEmpty(overlay.Notes, entry.Notes)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func joinNonEmpty(sep string, values ...string) string {
parts := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
parts = append(parts, value)
}
}
return strings.Join(parts, sep)
}
func resolveFFmpegBinary() (string, error) {
if configured := strings.TrimSpace(os.Getenv("SOW_FFMPEG")); configured != "" {
return configured, nil
}
path, err := exec.LookPath("ffmpeg")
if err != nil {
return "", fmt.Errorf("ffmpeg not found; install ffmpeg or set SOW_FFMPEG")
}
return path, nil
}
func resolveFFprobeBinary() (string, error) {
if configured := strings.TrimSpace(os.Getenv("SOW_FFPROBE")); configured != "" {
return configured, nil
}
path, err := exec.LookPath("ffprobe")
if err != nil {
return "", fmt.Errorf("ffprobe not found; install ffprobe or set SOW_FFPROBE")
}
return path, nil
}
func probeMusicMetadata(ffprobePath, sourcePath string) (musicMetadata, error) {
cmd := exec.Command(ffprobePath, "-v", "quiet", "-print_format", "json", "-show_format", sourcePath)
output, err := cmd.Output()
if err != nil {
return musicMetadata{}, err
}
var payload struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
}
if err := json.Unmarshal(output, &payload); err != nil {
return musicMetadata{}, err
}
tags := make(map[string]string, len(payload.Format.Tags))
for key, value := range payload.Format.Tags {
tags[strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
}
return musicMetadata{
Title: firstNonEmpty(tags["title"]),
Artist: firstNonEmpty(tags["artist"], tags["album_artist"], tags["composer"]),
Album: firstNonEmpty(tags["album"]),
Date: firstNonEmpty(tags["date"], tags["year"]),
Comment: firstNonEmpty(tags["comment"], tags["description"]),
Copyright: firstNonEmpty(tags["copyright"]),
License: firstNonEmpty(tags["license"], tags["license_url"]),
}, nil
}
func convertMusicSource(ffmpegPath, sourcePath, outputPath string) error {
cmd := exec.Command(
ffmpegPath, "-y",
"-i", sourcePath,
"-vn",
"-map_metadata", "-1",
"-ar", "44100",
"-ac", "2",
"-b:a", "192k",
"-codec:a", "libmp3lame",
"-write_xing", "0",
"-f", "mp3",
outputPath,
)
output, err := cmd.CombinedOutput()
if err != nil {
message := strings.TrimSpace(string(output))
if message == "" {
message = err.Error()
}
return fmt.Errorf("%s", message)
}
return nil
}
func isMusicAssetPath(rel string) bool {
rel = filepath.ToSlash(strings.TrimSpace(rel))
return rel == "envi/music" || strings.HasPrefix(rel, "envi/music/")
}
func musicPrefixForDir(p *project.Project, dir string) string {
dir = trimSlashes(filepath.ToSlash(dir))
bestPrefix := ""
bestLen := -1
for key, value := range p.Config.Music.Prefixes {
normalizedKey := trimSlashes(filepath.ToSlash(key))
if normalizedKey == "" {
continue
}
if dir != normalizedKey && !strings.HasPrefix(dir, normalizedKey+"/") {
continue
}
if len(normalizedKey) > bestLen {
bestLen = len(normalizedKey)
bestPrefix = sanitizePrefix(value)
}
}
return bestPrefix
}
func sanitizePrefix(prefix string) string {
prefix = strings.ToLower(strings.TrimSpace(prefix))
var builder strings.Builder
for _, r := range prefix {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_':
builder.WriteRune(r)
}
}
result := builder.String()
if strings.TrimSpace(prefix) != "" && strings.HasSuffix(strings.TrimSpace(prefix), "_") && !strings.HasSuffix(result, "_") {
result += "_"
}
return result
}
func trimSlashes(value string) string {
return strings.Trim(strings.TrimSpace(value), "/")
}
func generateMusicStem(fileName, prefix string, used map[string]struct{}) (string, error) {
maxCore := maxMusicStemLen - len(prefix)
if maxCore < 3 {
return "", fmt.Errorf("prefix too long: %q", prefix)
}
base := strings.TrimSuffix(fileName, filepath.Ext(fileName))
words := slugWords(base)
core := makeMusicCore(words, maxCore)
if core == "" {
sum := sha1.Sum([]byte(fileName))
core = fmt.Sprintf("%x", sum[:])[:maxCore]
}
stem := strings.TrimRight((prefix + core)[:min(maxMusicStemLen, len(prefix)+len(core))], "_")
if stem == "" {
return "", fmt.Errorf("could not derive music stem for %s", fileName)
}
return uniqueMusicName(stem, used), nil
}
func slugWords(value string) []string {
value = musicBracketRE.ReplaceAllString(value, " ")
value = strings.ReplaceAll(value, "&", " and ")
value = strings.ReplaceAll(value, "'", "")
value = strings.ReplaceAll(value, "’", "")
value = strings.ToLower(value)
var ascii strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
ascii.WriteRune(r)
case r >= '0' && r <= '9':
ascii.WriteRune(r)
default:
ascii.WriteRune(' ')
}
}
rawWords := musicSplitRE.Split(ascii.String(), -1)
words := make([]string, 0, len(rawWords))
for _, word := range rawWords {
if word == "" {
continue
}
if _, drop := musicDropWords[word]; drop {
continue
}
if musicYearRE.MatchString(word) {
continue
}
words = append(words, word)
}
return words
}
func makeMusicCore(words []string, maxCore int) string {
core := ""
for _, word := range words {
candidate := word
if core != "" {
candidate = core + "_" + word
}
if len(candidate) <= maxCore {
core = candidate
continue
}
break
}
if core != "" {
return core
}
abbrev := make([]string, 0, min(4, len(words)))
for _, word := range words {
if len(abbrev) == 4 {
break
}
abbrev = append(abbrev, abbreviateMusicWord(word, 5))
}
for _, word := range abbrev {
candidate := word
if core != "" {
candidate = core + "_" + word
}
if len(candidate) <= maxCore {
core = candidate
continue
}
break
}
if core != "" {
return core
}
if len(words) > 0 {
return strings.Trim(words[0][:min(maxCore, len(words[0]))], "_")
}
return ""
}
func abbreviateMusicWord(word string, maxLen int) string {
if len(word) <= maxLen {
return word
}
consonants := musicVowelRE.ReplaceAllString(word, "")
candidate := word[:1]
if len(consonants) > 1 {
candidate += consonants[1:]
}
if len(candidate) >= 3 {
return candidate[:min(maxLen, len(candidate))]
}
return word[:min(maxLen, len(word))]
}
func uniqueMusicName(stem string, used map[string]struct{}) string {
if _, exists := used[stem]; !exists {
used[stem] = struct{}{}
return stem
}
for index := 1; ; index++ {
suffix := fmt.Sprintf("_%d", index)
candidate := strings.TrimRight(stem[:max(0, maxMusicStemLen-len(suffix))], "_") + suffix
if _, exists := used[candidate]; exists {
continue
}
used[candidate] = struct{}{}
return candidate
}
}
func reserveMusicName(stem string, used map[string]struct{}) error {
stem = strings.ToLower(strings.TrimSpace(stem))
if stem == "" {
return fmt.Errorf("output filename is empty")
}
if _, exists := used[stem]; exists {
return fmt.Errorf("output filename %s collides with an existing asset", stem)
}
used[stem] = struct{}{}
return nil
}
func validateManualOutputFile(name string) error {
name = strings.ToLower(strings.TrimSpace(name))
if !strings.HasSuffix(name, ".bmu") {
return fmt.Errorf("output file must end with .bmu")
}
stem := strings.TrimSuffix(name, ".bmu")
if stem == "" {
return fmt.Errorf("output file stem is empty")
}
if len(stem) > maxMusicStemLen {
return fmt.Errorf("output file stem %q exceeds %d characters", stem, maxMusicStemLen)
}
for _, r := range stem {
switch {
case r >= 'a' && r <= 'z':
case r >= '0' && r <= '9':
case r == '_':
default:
return fmt.Errorf("output file stem %q contains unsupported character %q", stem, string(r))
}
}
return nil
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}