Files
rustytorch/crates/models/rtx-csm/scripts/emotional_speech_n.sh
T
osobhandClaude Opus 4.7 b7b267b701 rtx-csm: emotional_speech_n.sh — composite score with length floor
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]>
2026-04-29 21:35:32 -07:00

175 lines
6.1 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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