Original picker used min(WER), tie-broken by max(cosine). Fragile:
on emotion=surprised it picked "You can." (2 words, WER 0.93) over
"...Today I want to share something" (13 words, WER 1.00) because
WER weights all errors uniformly — terse-and-mostly-wrong beats
long-and-mostly-right.
New scoring:
score = WER + (1.0 if words(transcript) < 5 else 0)
sort_by(score, -cosine)
Verified on existing benches:
surprised: now picks seed=100 ("...Today I want to share something
with...", 13 words, score 1.0) over seed=7 ("You can.",
2 words, score 1.929 with +1 length penalty).
calm: still picks seed=100 (full transcript revealed: "It's a
good reflection. Not that that. I want to share
something with you that I've been thinking about." —
near-verbatim! the earlier 55-char display had been
truncating it).
disgust: all 3 candidates score ~1.93 (no seed has > 5 words,
all get the length penalty); picker honestly admits
none is good rather than picking a fake winner.
Worth noting: the calm seed=100 case is ANOTHER near-verbatim
single-shot result we missed in the previous bench because the
display truncation hid the full transcript content.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
175 lines
6.1 KiB
Bash
Executable File
175 lines
6.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# Roll N seeds through `emotional_speech.sh`, score each with
|
||
# `examples/quality_eval`, copy the lowest-WER output to --out.
|
||
#
|
||
# Why: Sprint 2 reproducibility bench (2026-04-29) showed the recipe
|
||
# has high seed variance for text content fidelity. Same prompt
|
||
# rendered "Today, today I want to share..." (WER 0.71) at seed 7
|
||
# and "The police are, if you're..." (WER 2.0) at seed 42. Speaker
|
||
# cosine is consistently elevated across seeds (the steering shifts
|
||
# voice character reliably), but content is roll-the-dice. This
|
||
# wrapper rolls the dice multiple times automatically and picks the
|
||
# winner.
|
||
#
|
||
# Cost: N × the single-shot cost. Defaults to 5 seeds. Lower N if
|
||
# the recipe is reliable for your prompt category; raise if you need
|
||
# the cleanest possible output.
|
||
#
|
||
# Usage:
|
||
# scripts/emotional_speech_n.sh \
|
||
# --text "..." \
|
||
# --context-wav <ref>.wav --context-text "..." \
|
||
# --emotion happy --steering-dir /tmp/ravdess_steering \
|
||
# --out /tmp/best.wav \
|
||
# [--seeds 42,7,100,123,256]
|
||
# [--wavlm-sv /tmp/wavlm_sv.safetensors]
|
||
# [--keep-all] # keep all candidates in <out_dir>/candidates/
|
||
# [other flags pass through to emotional_speech.sh]
|
||
|
||
set -euo pipefail
|
||
|
||
TEXT=""
|
||
CTX_WAV=""
|
||
CTX_TEXT=""
|
||
EMOTION=""
|
||
STEERING_DIR=""
|
||
OUT=""
|
||
SEEDS="42,7,100,123,256"
|
||
WAVLM_SV="/tmp/wavlm_sv.safetensors"
|
||
KEEP_ALL=false
|
||
PASSTHROUGH=()
|
||
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--text) TEXT="$2"; shift 2 ;;
|
||
--context-wav) CTX_WAV="$2"; shift 2 ;;
|
||
--context-text) CTX_TEXT="$2"; shift 2 ;;
|
||
--emotion) EMOTION="$2"; shift 2 ;;
|
||
--steering-dir) STEERING_DIR="$2"; shift 2 ;;
|
||
--out) OUT="$2"; shift 2 ;;
|
||
--seeds) SEEDS="$2"; shift 2 ;;
|
||
--wavlm-sv) WAVLM_SV="$2"; shift 2 ;;
|
||
--keep-all) KEEP_ALL=true; shift 1 ;;
|
||
-h|--help) sed -n '2,30p' "$0"; exit 0 ;;
|
||
# Pass any unknown flags through to emotional_speech.sh
|
||
*) PASSTHROUGH+=("$1"); shift 1 ;;
|
||
esac
|
||
done
|
||
|
||
for v in TEXT CTX_WAV CTX_TEXT EMOTION STEERING_DIR OUT; do
|
||
if [[ -z "${!v}" ]]; then
|
||
echo "error: --${v,,} unset" 2>/dev/null || true
|
||
echo "usage: $0 --text T --context-wav W --context-text C --emotion E --steering-dir D --out O [--seeds 42,7,100]" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
if [[ ! -f "$WAVLM_SV" ]]; then
|
||
echo "wavlm-sv not found: $WAVLM_SV" >&2; exit 1
|
||
fi
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
WORKSPACE_DIR="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
|
||
EVAL_BIN="$WORKSPACE_DIR/target/release/examples/quality_eval"
|
||
EMO_SCRIPT="$SCRIPT_DIR/emotional_speech.sh"
|
||
for f in "$EVAL_BIN" "$EMO_SCRIPT"; do
|
||
if [[ ! -x "$f" ]]; then
|
||
echo "missing: $f" >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
|
||
# Working directory derived from --out path.
|
||
OUT_DIR="$(dirname "$OUT")"
|
||
mkdir -p "$OUT_DIR"
|
||
CAND_DIR="$OUT_DIR/candidates_$(date +%s%N)"
|
||
mkdir -p "$CAND_DIR"
|
||
trap '[[ "$KEEP_ALL" == false ]] && rm -rf "$CAND_DIR"' EXIT
|
||
|
||
# Comma-split seeds into an array.
|
||
IFS=',' read -ra SEED_ARR <<< "$SEEDS"
|
||
|
||
echo "→ rolling ${#SEED_ARR[@]} seeds for emotion='$EMOTION' on:"
|
||
echo " \"$(echo "$TEXT" | head -c 70)...\""
|
||
|
||
# Generate one candidate per seed.
|
||
PAIRS_FILE="$CAND_DIR/eval_pairs.jsonl"
|
||
: > "$PAIRS_FILE"
|
||
for seed in "${SEED_ARR[@]}"; do
|
||
candidate="$CAND_DIR/seed_${seed}.wav"
|
||
"$EMO_SCRIPT" \
|
||
--text "$TEXT" \
|
||
--context-wav "$CTX_WAV" --context-text "$CTX_TEXT" \
|
||
--emotion "$EMOTION" --steering-dir "$STEERING_DIR" \
|
||
--seed "$seed" \
|
||
--out "$candidate" \
|
||
${PASSTHROUGH[@]+"${PASSTHROUGH[@]}"} 2>&1 | grep -E "recipe|generated" | sed "s/^/ [seed=$seed] /" || true
|
||
|
||
# Skip rows for outputs that didn't exist (e.g. rare crashes).
|
||
if [[ -f "$candidate" ]]; then
|
||
jq -nc --arg ref "$CTX_WAV" --arg gen "$candidate" \
|
||
--arg t "$TEXT" --arg s "$seed" \
|
||
'{ref_wav: $ref, gen_wav: $gen, ref_text: $t, seed: $s}' >> "$PAIRS_FILE"
|
||
fi
|
||
done
|
||
|
||
# Score everything in one quality_eval pass (reuses model load).
|
||
SCORES_FILE="$CAND_DIR/scores.jsonl"
|
||
"$EVAL_BIN" \
|
||
--in "$PAIRS_FILE" \
|
||
--out "$SCORES_FILE" \
|
||
--wavlm-sv "$WAVLM_SV" 2>&1 | tail -3
|
||
|
||
echo
|
||
echo "=== candidates (composite score: WER + length floor) ==="
|
||
# Composite score per candidate:
|
||
# score = WER + length_penalty
|
||
# where length_penalty = 1.0 when transcript has < MIN_WORDS words
|
||
# (sinks ultra-short outputs that game raw-WER by being terse).
|
||
# Originally we used min(WER) tie-broken by max(cosine), but that
|
||
# preferred "You can." (3 words, WER 0.93) over "...Today I want to
|
||
# share something" (8+ words, WER 1.00) on emotion=surprised.
|
||
# Length floor inverts that ranking.
|
||
MIN_WORDS=5
|
||
jq -r --argjson minw "$MIN_WORDS" '
|
||
. as $r |
|
||
($r.metrics.transcript // "" | split(" ") | map(select(length>0)) | length) as $words |
|
||
[
|
||
$r.seed,
|
||
((.metrics.speaker_cosine // -1) * 1000 | round / 1000),
|
||
((.metrics.wer // 99) * 1000 | round / 1000),
|
||
$words,
|
||
(if $words < $minw then "+L" else "" end),
|
||
((.metrics.transcript // "(no transcript)")[0:55])
|
||
] | @tsv
|
||
' "$SCORES_FILE" \
|
||
| awk -F'\t' -v minw="$MIN_WORDS" '
|
||
BEGIN{ printf "%-6s %-7s %-7s %-7s %-3s %s\n","seed","cos","WER","words","pen","transcript" }
|
||
{ score = ($3 + 0) + (($4 < minw) ? 1.0 : 0.0)
|
||
printf "%-6s %-7s %-7s %-7s %-3s %s [score=%.3f]\n",$1,$2,$3,$4,$5,$6,score
|
||
}
|
||
' | sort -t '=' -k2,2g
|
||
|
||
# Pick winner via jq -s with the same composite formula.
|
||
WINNER_JQ='
|
||
map(. + {
|
||
_words: ((.metrics.transcript // "") | split(" ") | map(select(length>0)) | length),
|
||
_cos: (.metrics.speaker_cosine // -1)
|
||
})
|
||
| map(. + { _score: ((.metrics.wer // 99) + (if ._words < 5 then 1.0 else 0.0 end)) })
|
||
| sort_by(._score, -._cos)
|
||
| .[0]
|
||
'
|
||
WINNER_PATH=$(jq -s "$WINNER_JQ"' | .gen_wav' "$SCORES_FILE" | tr -d '"')
|
||
WINNER_SEED=$(jq -s "$WINNER_JQ"' | .seed' "$SCORES_FILE" | tr -d '"')
|
||
|
||
if [[ -z "$WINNER_PATH" || ! -f "$WINNER_PATH" ]]; then
|
||
echo "error: no candidate to copy as winner" >&2
|
||
exit 2
|
||
fi
|
||
cp "$WINNER_PATH" "$OUT"
|
||
echo
|
||
echo "✓ winner seed=$WINNER_SEED → $OUT"
|
||
if [[ "$KEEP_ALL" == true ]]; then
|
||
echo " all candidates kept in $CAND_DIR/"
|
||
fi
|