mirror app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,1818 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Glossolalia Dial — a single dial that grades a typed lyric into dreamy territory in two
|
| 2 |
+
distinct phonotactic paths:
|
| 3 |
+
|
| 4 |
+
Ghost mode: lyric is rewritten as a sequence of real English words (mondegreen substitution).
|
| 5 |
+
Constrained by syllable count, primary-stress position, PanPhon feature-edit
|
| 6 |
+
distance; reranked by DistilGPT-2 for semantic coherence. F5-TTS base reads it.
|
| 7 |
+
Tongues mode: clean lyric goes into F5-TTS + a fine-tuned LoRA + a learned scalar conditioner
|
| 8 |
+
(LevelEmbed at AdaLN side). The LoRA produces graded glossolalic audio in the
|
| 9 |
+
user's chosen voice — invented pseudowords, sonorant-leaning palette.
|
| 10 |
+
|
| 11 |
+
Both modes ride F5-TTS for voice cloning + audio synthesis. Off-the-Grid: no cloud APIs.
|
| 12 |
+
|
| 13 |
+
v1 Gradio app (gr.Blocks). v2 (Off-Brand badge) is in app_server.py.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import re
|
| 20 |
+
import tempfile
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
import gradio as gr
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
from config import (
|
| 27 |
+
CONTROL_STEM, HF_LORA_REPO, LEVEL_WORDS, RESEMBLYZER_MIN_COSINE,
|
| 28 |
+
SAMPLE_RATE, VOICE_PRESETS, WHISPER_MODEL,
|
| 29 |
+
)
|
| 30 |
+
from scripts.post_fx import PRESETS as POSTFX_PRESETS, apply_post_fx
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _parse_per_word_overrides(text: str) -> dict[str, tuple[str, float]]:
|
| 34 |
+
"""Parse a string like 'river=ree-vuh:1.3 calm=kawm light=:1.6' into a dict:
|
| 35 |
+
{ 'river': ('ree-vuh', 1.3), 'calm': ('kawm', 1.0), 'light': ('light', 1.6) }
|
| 36 |
+
The 'word=replacement:stretch' format is intentionally simple:
|
| 37 |
+
- word: the source word in the input lyric (case-insensitive)
|
| 38 |
+
- replacement: the pronunciation guide we feed to F5-TTS (empty -> keep original)
|
| 39 |
+
- stretch: a speed multiplier for THAT word's audio chunk (1.0 = normal,
|
| 40 |
+
<1.0 = faster, >1.0 = slower / more sustained). Default 1.0 if omitted.
|
| 41 |
+
"""
|
| 42 |
+
out: dict[str, tuple[str, float]] = {}
|
| 43 |
+
for tok in (text or "").split():
|
| 44 |
+
if "=" not in tok:
|
| 45 |
+
continue
|
| 46 |
+
word, rest = tok.split("=", 1)
|
| 47 |
+
word = word.strip().lower()
|
| 48 |
+
if ":" in rest:
|
| 49 |
+
repl, sval = rest.split(":", 1)
|
| 50 |
+
try:
|
| 51 |
+
stretch = float(sval)
|
| 52 |
+
except ValueError:
|
| 53 |
+
stretch = 1.0
|
| 54 |
+
else:
|
| 55 |
+
repl, stretch = rest, 1.0
|
| 56 |
+
stretch = max(0.5, min(2.5, stretch))
|
| 57 |
+
out[word] = (repl.strip() if repl.strip() else word, stretch)
|
| 58 |
+
return out
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _chunked_generate_with_overrides(
|
| 62 |
+
engine, sentence: str, voice_id: str, level: int, seed: int, mode: str,
|
| 63 |
+
custom_voice_path: str | None, custom_voice_text: str,
|
| 64 |
+
overrides: dict[str, tuple[str, float]]) -> tuple[np.ndarray, int, str]:
|
| 65 |
+
"""Generate audio chunk-by-chunk so per-word stretch + pronunciation overrides apply.
|
| 66 |
+
Each chunk goes through F5-TTS at its own speed, then we equal-power concat.
|
| 67 |
+
Falls back to the single-shot path if no overrides are present."""
|
| 68 |
+
if not overrides:
|
| 69 |
+
return engine.generate(sentence, voice_id, level, seed=seed, mode=mode,
|
| 70 |
+
custom_voice_path=custom_voice_path,
|
| 71 |
+
custom_voice_text=custom_voice_text)
|
| 72 |
+
words = re.findall(r"[A-Za-z']+|[^A-Za-z']+", sentence)
|
| 73 |
+
chunks: list[tuple[str, float]] = []
|
| 74 |
+
for w in words:
|
| 75 |
+
key = w.lower().strip()
|
| 76 |
+
if key in overrides:
|
| 77 |
+
repl, stretch = overrides[key]
|
| 78 |
+
chunks.append((repl, stretch))
|
| 79 |
+
else:
|
| 80 |
+
chunks.append((w, 1.0))
|
| 81 |
+
# Merge adjacent chunks with stretch == 1.0 so we don't call F5-TTS 30 times for one sentence
|
| 82 |
+
merged: list[tuple[str, float]] = []
|
| 83 |
+
for text_part, st in chunks:
|
| 84 |
+
if merged and abs(merged[-1][1] - 1.0) < 1e-6 and abs(st - 1.0) < 1e-6:
|
| 85 |
+
merged[-1] = (merged[-1][0] + text_part, 1.0)
|
| 86 |
+
else:
|
| 87 |
+
merged.append((text_part, st))
|
| 88 |
+
# Generate each chunk
|
| 89 |
+
audios = []
|
| 90 |
+
sr_out = SAMPLE_RATE
|
| 91 |
+
last_gen_text_acc = ""
|
| 92 |
+
for text_part, stretch in merged:
|
| 93 |
+
if not text_part.strip():
|
| 94 |
+
continue
|
| 95 |
+
y, sr, gen_text = engine.generate(
|
| 96 |
+
text_part, voice_id, level, seed=seed, mode=mode,
|
| 97 |
+
custom_voice_path=custom_voice_path,
|
| 98 |
+
custom_voice_text=custom_voice_text,
|
| 99 |
+
)
|
| 100 |
+
sr_out = sr
|
| 101 |
+
if abs(stretch - 1.0) > 0.02:
|
| 102 |
+
try:
|
| 103 |
+
import librosa
|
| 104 |
+
y = librosa.effects.time_stretch(y.astype(np.float32), rate=1.0 / stretch)
|
| 105 |
+
except Exception as e:
|
| 106 |
+
print(f"[chunked] time_stretch failed for '{text_part}': {e}")
|
| 107 |
+
audios.append(y)
|
| 108 |
+
last_gen_text_acc += gen_text + " "
|
| 109 |
+
if not audios:
|
| 110 |
+
return engine.generate(sentence, voice_id, level, seed=seed, mode=mode,
|
| 111 |
+
custom_voice_path=custom_voice_path,
|
| 112 |
+
custom_voice_text=custom_voice_text)
|
| 113 |
+
final = equal_power_concat(audios, sr_out, fade_ms=80)
|
| 114 |
+
return final.astype(np.float32), sr_out, last_gen_text_acc.strip()
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _blend_with_music(vocal: np.ndarray, vocal_sr: int, music_path: str,
|
| 118 |
+
vocal_gain_db: float = 0.0, music_gain_db: float = -8.0,
|
| 119 |
+
tempo_lock: bool = True, autotune: bool = True
|
| 120 |
+
) -> tuple[np.ndarray, int]:
|
| 121 |
+
"""Mix the TTS vocal over an uploaded music track with TEMPO LOCK and AUTOTUNE.
|
| 122 |
+
|
| 123 |
+
Tempo lock:
|
| 124 |
+
- Detect music tempo via librosa.beat.beat_track
|
| 125 |
+
- Detect vocal "tempo" proxy from the speech onset rate
|
| 126 |
+
- Time-stretch the vocal to align (cap at +/-25% to keep formants).
|
| 127 |
+
|
| 128 |
+
Autotune (rough whole-clip pitch shift):
|
| 129 |
+
- Detect music's dominant pitch class via chroma_cqt average
|
| 130 |
+
- Detect vocal median f0 via librosa.yin
|
| 131 |
+
- Compute semitones from vocal_f0 to the nearest octave of the music's root note
|
| 132 |
+
- Pitch-shift the vocal by that many semitones (capped at +/-7 to avoid chipmunk)
|
| 133 |
+
|
| 134 |
+
All local: librosa + numpy. Off-the-Grid stays clean.
|
| 135 |
+
"""
|
| 136 |
+
import librosa
|
| 137 |
+
import math
|
| 138 |
+
|
| 139 |
+
music, music_sr = librosa.load(music_path, sr=vocal_sr, mono=True)
|
| 140 |
+
|
| 141 |
+
# --- tempo lock ---
|
| 142 |
+
if tempo_lock and len(music) > vocal_sr * 2:
|
| 143 |
+
try:
|
| 144 |
+
mtempo, _ = librosa.beat.beat_track(y=music, sr=music_sr)
|
| 145 |
+
vtempo, _ = librosa.beat.beat_track(y=vocal, sr=vocal_sr)
|
| 146 |
+
if mtempo and vtempo and abs(np.log2(mtempo / vtempo)) < 1.5:
|
| 147 |
+
ratio = float(np.clip(vtempo / mtempo, 0.78, 1.28))
|
| 148 |
+
if abs(ratio - 1.0) > 0.04:
|
| 149 |
+
vocal = librosa.effects.time_stretch(vocal, rate=ratio)
|
| 150 |
+
except Exception as e:
|
| 151 |
+
print(f"[blend] tempo lock failed: {e}")
|
| 152 |
+
|
| 153 |
+
# --- autotune: detect music root + pitch-shift vocal toward it ---
|
| 154 |
+
if autotune:
|
| 155 |
+
try:
|
| 156 |
+
# 12-bin chroma -> dominant pitch class is the music's key center
|
| 157 |
+
chroma = librosa.feature.chroma_cqt(y=music, sr=music_sr, hop_length=2048)
|
| 158 |
+
key_idx = int(np.argmax(chroma.mean(axis=1))) # 0=C, 1=C#, ..., 11=B
|
| 159 |
+
key_names = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"]
|
| 160 |
+
# vocal median f0 via YIN
|
| 161 |
+
f0 = librosa.yin(vocal.astype(np.float32),
|
| 162 |
+
fmin=70, fmax=500, sr=vocal_sr, frame_length=2048)
|
| 163 |
+
f0 = f0[~np.isnan(f0) & (f0 > 0)]
|
| 164 |
+
if len(f0) > 5:
|
| 165 |
+
vocal_f0 = float(np.median(f0))
|
| 166 |
+
# music root note across plausible vocal octaves: 65.4 Hz (C2) .. 523 Hz (C5)
|
| 167 |
+
root_freqs = [
|
| 168 |
+
27.5 * (2 ** ((key_idx - 9 + 12 * o) / 12.0)) # A0=27.5; key_idx 9 == A
|
| 169 |
+
for o in range(2, 7)
|
| 170 |
+
]
|
| 171 |
+
# pick the root octave closest in log-space to the vocal's median pitch
|
| 172 |
+
root = min(root_freqs, key=lambda r: abs(math.log2(r / vocal_f0)))
|
| 173 |
+
semitones = round(12 * math.log2(root / vocal_f0))
|
| 174 |
+
semitones = max(-7, min(7, semitones))
|
| 175 |
+
if semitones != 0:
|
| 176 |
+
vocal = librosa.effects.pitch_shift(
|
| 177 |
+
vocal.astype(np.float32), sr=vocal_sr, n_steps=semitones)
|
| 178 |
+
print(f"[blend] autotune: music key={key_names[key_idx]}, "
|
| 179 |
+
f"vocal_f0={vocal_f0:.1f}Hz -> root={root:.1f}Hz, "
|
| 180 |
+
f"shift {semitones} semitones")
|
| 181 |
+
except Exception as e:
|
| 182 |
+
print(f"[blend] autotune failed: {e}")
|
| 183 |
+
|
| 184 |
+
# --- mix ---
|
| 185 |
+
n = max(len(music), len(vocal))
|
| 186 |
+
if len(vocal) < n: vocal = np.pad(vocal, (0, n - len(vocal)))
|
| 187 |
+
if len(music) < n: music = np.pad(music, (0, n - len(music)))
|
| 188 |
+
vocal_gain = 10.0 ** (vocal_gain_db / 20.0)
|
| 189 |
+
music_gain = 10.0 ** (music_gain_db / 20.0)
|
| 190 |
+
out = vocal_gain * vocal + music_gain * music
|
| 191 |
+
peak = float(np.max(np.abs(out)) + 1e-9)
|
| 192 |
+
if peak > 0.98:
|
| 193 |
+
out = out * (0.98 / peak)
|
| 194 |
+
return out.astype(np.float32), vocal_sr
|
| 195 |
+
|
| 196 |
+
VOICE_IDS = list(VOICE_PRESETS.keys())
|
| 197 |
+
DEFAULT_VOICE = VOICE_IDS[0] if VOICE_IDS else "v1"
|
| 198 |
+
DEFAULT_TEXT = "she sells seashells by the seashore"
|
| 199 |
+
# default to the published LoRA repo so the Space loads it without needing env vars;
|
| 200 |
+
# COHERENCE_DIAL_LORA env var overrides for local dev against a checkpoint dir.
|
| 201 |
+
LORA_PATH = os.environ.get("COHERENCE_DIAL_LORA", HF_LORA_REPO)
|
| 202 |
+
|
| 203 |
+
MODE_GHOST = "Ghost"
|
| 204 |
+
MODE_TONGUES = "Tongues"
|
| 205 |
+
MODES = (MODE_GHOST, MODE_TONGUES)
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
# ----- inference engine (lazy-loaded; falls back to a silent stub if F5-TTS isn't installed) -----
|
| 209 |
+
|
| 210 |
+
class TTSEngine:
|
| 211 |
+
"""Dual-mode engine. One F5-TTS instance with the v8 LoRA loaded; mode is selected per
|
| 212 |
+
inference call. For Ghost mode we set_dial(0) (LevelEmbed contributes ~zero) and feed
|
| 213 |
+
mondegreen-substituted text. For Tongues mode we set_dial(level) and feed the clean
|
| 214 |
+
lyric. The base LoRA attention adaptation is always on, but at dial=0 it produces audio
|
| 215 |
+
indistinguishable from F5-TTS base (verified empirically by v5 sweep — lv0 sounded
|
| 216 |
+
identical to base output)."""
|
| 217 |
+
|
| 218 |
+
def __init__(self):
|
| 219 |
+
self._tts = None
|
| 220 |
+
self._asr = None
|
| 221 |
+
self._enc = None
|
| 222 |
+
self._mondegreen = None
|
| 223 |
+
self._lm = None
|
| 224 |
+
self._lora_loaded = False
|
| 225 |
+
self.live = False
|
| 226 |
+
|
| 227 |
+
def _ensure_mondegreen(self):
|
| 228 |
+
"""Lazy load the deterministic phonetic-ghost generator + DistilGPT-2 reranker."""
|
| 229 |
+
if self._mondegreen is not None:
|
| 230 |
+
return
|
| 231 |
+
try:
|
| 232 |
+
from scripts.mondegreen import MondegreenIndex, LMReranker
|
| 233 |
+
self._mondegreen = MondegreenIndex("data/cmudict.dict")
|
| 234 |
+
print(f"[engine] Mondegreen index loaded ({self._mondegreen.size} words)")
|
| 235 |
+
self._lm = LMReranker()
|
| 236 |
+
print("[engine] DistilGPT-2 reranker loaded")
|
| 237 |
+
except Exception as e:
|
| 238 |
+
print(f"[engine] mondegreen load FAILED ({e}); Ghost mode falls back to clean text")
|
| 239 |
+
self._mondegreen = False
|
| 240 |
+
self._lm = False
|
| 241 |
+
|
| 242 |
+
def ghost_text(self, sentence: str, level: int, seed: int = 42) -> str:
|
| 243 |
+
"""Deterministic Ghost mode substitution. Returns the source if mondegreen unavailable."""
|
| 244 |
+
self._ensure_mondegreen()
|
| 245 |
+
if not self._mondegreen:
|
| 246 |
+
return sentence
|
| 247 |
+
return self._mondegreen.substitute(sentence, level, seed=seed,
|
| 248 |
+
reranker=(self._lm or None))
|
| 249 |
+
|
| 250 |
+
def _ensure(self):
|
| 251 |
+
if self._tts is not None:
|
| 252 |
+
return
|
| 253 |
+
try:
|
| 254 |
+
import patches # noqa: F401 — installs F5TTS.load_lora before instantiation
|
| 255 |
+
from f5_tts.api import F5TTS
|
| 256 |
+
self._tts = F5TTS(model="F5TTS_v1_Base")
|
| 257 |
+
self.live = True
|
| 258 |
+
print(f"[engine] F5-TTS base loaded (model=F5TTS_v1_Base)")
|
| 259 |
+
if LORA_PATH:
|
| 260 |
+
try:
|
| 261 |
+
self._tts.load_lora(LORA_PATH)
|
| 262 |
+
self._lora_loaded = True
|
| 263 |
+
print(f"[engine] LoRA loaded from {LORA_PATH}")
|
| 264 |
+
except Exception as e:
|
| 265 |
+
print(f"[engine] LoRA load FAILED ({e}); falling back to base model — Well-Tuned badge forfeit")
|
| 266 |
+
else:
|
| 267 |
+
print("[engine] no LoRA path configured; running base model only")
|
| 268 |
+
except ImportError:
|
| 269 |
+
print("[engine] f5-tts not installed; running with silent stub for layout testing")
|
| 270 |
+
self.live = False
|
| 271 |
+
|
| 272 |
+
def _ensure_asr(self):
|
| 273 |
+
if self._asr is None:
|
| 274 |
+
try:
|
| 275 |
+
import whisper
|
| 276 |
+
self._asr = whisper.load_model(WHISPER_MODEL)
|
| 277 |
+
except Exception:
|
| 278 |
+
self._asr = False
|
| 279 |
+
return self._asr
|
| 280 |
+
|
| 281 |
+
def _ensure_encoder(self):
|
| 282 |
+
if self._enc is None:
|
| 283 |
+
try:
|
| 284 |
+
from resemblyzer import VoiceEncoder
|
| 285 |
+
self._enc = VoiceEncoder()
|
| 286 |
+
except Exception:
|
| 287 |
+
self._enc = False
|
| 288 |
+
return self._enc
|
| 289 |
+
|
| 290 |
+
def generate(self, sentence: str, voice_id: str, level: int, seed: int = 42,
|
| 291 |
+
mode: str = MODE_TONGUES, custom_voice_path: str | None = None,
|
| 292 |
+
custom_voice_text: str = ""):
|
| 293 |
+
"""Returns (audio float32 mono, sample_rate, gen_text_used).
|
| 294 |
+
|
| 295 |
+
mode=MODE_GHOST: substitute lyric via mondegreen at given level, set_dial(0), TTS reads it.
|
| 296 |
+
mode=MODE_TONGUES: leave lyric clean, set_dial(level), LoRA conditions glossolalic audio.
|
| 297 |
+
|
| 298 |
+
custom_voice_path: if a path is supplied, F5-TTS clones this clip instead of the
|
| 299 |
+
preset voice. custom_voice_text is the transcript of that clip (improves clone quality;
|
| 300 |
+
an empty string lets F5-TTS auto-transcribe via Whisper).
|
| 301 |
+
"""
|
| 302 |
+
self._ensure()
|
| 303 |
+
if custom_voice_path:
|
| 304 |
+
voice_wav = custom_voice_path
|
| 305 |
+
voice_ref_text = (custom_voice_text or "").strip()
|
| 306 |
+
else:
|
| 307 |
+
voice = VOICE_PRESETS[voice_id]
|
| 308 |
+
voice_wav = voice["wav"]
|
| 309 |
+
ref_txt = Path(voice["ref_text"])
|
| 310 |
+
voice_ref_text = ref_txt.read_text(encoding="utf-8").strip() if ref_txt.exists() else ""
|
| 311 |
+
if mode == MODE_GHOST:
|
| 312 |
+
gen_text = self.ghost_text(sentence, level, seed=seed)
|
| 313 |
+
tts_dial = 0
|
| 314 |
+
else:
|
| 315 |
+
gen_text = sentence
|
| 316 |
+
tts_dial = level
|
| 317 |
+
if not self.live:
|
| 318 |
+
return np.zeros(SAMPLE_RATE * 3, dtype=np.float32), SAMPLE_RATE, gen_text
|
| 319 |
+
if hasattr(self._tts, "set_dial"):
|
| 320 |
+
try:
|
| 321 |
+
self._tts.set_dial(tts_dial)
|
| 322 |
+
except Exception as e:
|
| 323 |
+
print(f"[engine] set_dial({tts_dial}) FAILED ({e}); proceeding without conditioning")
|
| 324 |
+
out = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
| 325 |
+
self._tts.infer(ref_file=voice_wav, ref_text=voice_ref_text,
|
| 326 |
+
gen_text=gen_text, file_wave=out, seed=seed)
|
| 327 |
+
import soundfile as sf
|
| 328 |
+
y, sr = sf.read(out, always_2d=False)
|
| 329 |
+
if y.ndim == 2:
|
| 330 |
+
y = y.mean(axis=1)
|
| 331 |
+
return y.astype(np.float32), sr, gen_text
|
| 332 |
+
|
| 333 |
+
def transcribe_wer(self, y: np.ndarray, sr: int, ref_text: str) -> float | None:
|
| 334 |
+
asr = self._ensure_asr()
|
| 335 |
+
if not asr:
|
| 336 |
+
return None
|
| 337 |
+
import soundfile as sf, jiwer
|
| 338 |
+
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
| 339 |
+
sf.write(tmp, y, sr)
|
| 340 |
+
out = asr.transcribe(tmp, fp16=False, language="en", condition_on_previous_text=False,
|
| 341 |
+
no_speech_threshold=0.8, logprob_threshold=-1.5)
|
| 342 |
+
hyp = (out.get("text") or "").strip()
|
| 343 |
+
if not hyp:
|
| 344 |
+
return 1.0
|
| 345 |
+
return float(min(jiwer.wer(ref_text.lower(), hyp.lower()), 1.0))
|
| 346 |
+
|
| 347 |
+
def voice_cosine(self, y: np.ndarray, sr: int, ref_y: np.ndarray, ref_sr: int) -> float | None:
|
| 348 |
+
enc = self._ensure_encoder()
|
| 349 |
+
if not enc:
|
| 350 |
+
return None
|
| 351 |
+
from resemblyzer import preprocess_wav
|
| 352 |
+
import soundfile as sf
|
| 353 |
+
a = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
| 354 |
+
b = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
| 355 |
+
sf.write(a, y, sr); sf.write(b, ref_y, ref_sr)
|
| 356 |
+
ea = enc.embed_utterance(preprocess_wav(a))
|
| 357 |
+
eb = enc.embed_utterance(preprocess_wav(b))
|
| 358 |
+
return float(np.dot(ea, eb) / ((np.linalg.norm(ea) * np.linalg.norm(eb)) + 1e-9))
|
| 359 |
+
|
| 360 |
+
|
| 361 |
+
ENGINE = TTSEngine()
|
| 362 |
+
|
| 363 |
+
|
| 364 |
+
# ----- crossfading for Morph mode -----
|
| 365 |
+
|
| 366 |
+
def equal_power_concat(clips, sr, fade_ms=200):
|
| 367 |
+
if not clips:
|
| 368 |
+
return np.zeros(1, dtype=np.float32)
|
| 369 |
+
fade_n = max(1, int(sr * fade_ms / 1000))
|
| 370 |
+
t = np.linspace(0, 1, fade_n, dtype=np.float32)
|
| 371 |
+
fi = np.sin(t * np.pi / 2.0)
|
| 372 |
+
fo = np.cos(t * np.pi / 2.0)
|
| 373 |
+
out = clips[0].astype(np.float32).copy()
|
| 374 |
+
for c in clips[1:]:
|
| 375 |
+
c = c.astype(np.float32)
|
| 376 |
+
if len(out) < fade_n or len(c) < fade_n:
|
| 377 |
+
out = np.concatenate([out, c]); continue
|
| 378 |
+
head = out[-fade_n:] * fo
|
| 379 |
+
tail = c[:fade_n] * fi
|
| 380 |
+
out = np.concatenate([out[:-fade_n], head + tail, c[fade_n:]])
|
| 381 |
+
return out
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _wav_to_filepath(y: np.ndarray, sr: int) -> str:
|
| 385 |
+
import soundfile as sf
|
| 386 |
+
if y.ndim == 2:
|
| 387 |
+
y = y.T # (channels, samples) -> (samples, channels)
|
| 388 |
+
path = tempfile.NamedTemporaryFile(suffix=".wav", delete=False).name
|
| 389 |
+
sf.write(path, y, sr)
|
| 390 |
+
return path
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# ----- readout (live metrics strip) -----
|
| 394 |
+
|
| 395 |
+
def readout(level: int | None = None, wer: float | None = None,
|
| 396 |
+
cosine: float | None = None, status: str = "") -> str:
|
| 397 |
+
cells = [
|
| 398 |
+
("DIAL", f"{level}" if level is not None else "·"),
|
| 399 |
+
("WER", f"{wer:.2f}" if wer is not None else "·"),
|
| 400 |
+
("VOICE-SIM", f"{cosine:.2f}" if cosine is not None else "·"),
|
| 401 |
+
("STATUS", status or ("live" if ENGINE.live else "stub")),
|
| 402 |
+
]
|
| 403 |
+
return "<div class='readout'>" + "".join(
|
| 404 |
+
f"<div class='readout-cell'><div class='readout-label'>{k}</div><div class='readout-val'>{v}</div></div>"
|
| 405 |
+
for k, v in cells
|
| 406 |
+
) + "</div>"
|
| 407 |
+
|
| 408 |
+
|
| 409 |
+
# ----- speak + morph handlers -----
|
| 410 |
+
|
| 411 |
+
def _safe_int(v, lo: int = 0, hi: int = 4) -> int:
|
| 412 |
+
try:
|
| 413 |
+
return max(lo, min(hi, int(float(v))))
|
| 414 |
+
except (TypeError, ValueError):
|
| 415 |
+
return lo
|
| 416 |
+
|
| 417 |
+
|
| 418 |
+
def _render_token_preview(lyric: str, overrides_text: str) -> str:
|
| 419 |
+
"""Render the lyric as clickable gold token chips. Edited tokens show their override.
|
| 420 |
+
Python-driven: gets called on every lyric / overrides change so the markup stays in sync."""
|
| 421 |
+
overrides = _parse_per_word_overrides(overrides_text or "")
|
| 422 |
+
if not lyric or not lyric.strip():
|
| 423 |
+
return '<div class="token-row token-empty">type a lyric above; click any word here to hand-tune it</div>'
|
| 424 |
+
parts = re.findall(r"[A-Za-z']+|\s+|[^A-Za-z'\s]+", lyric)
|
| 425 |
+
pieces = []
|
| 426 |
+
for p in parts:
|
| 427 |
+
if not p:
|
| 428 |
+
continue
|
| 429 |
+
if not re.search(r"[A-Za-z]", p):
|
| 430 |
+
# whitespace / punctuation passes through, render \n as <br>
|
| 431 |
+
pieces.append(p.replace("\n", "<br>"))
|
| 432 |
+
continue
|
| 433 |
+
key = p.lower().strip("'")
|
| 434 |
+
if key in overrides:
|
| 435 |
+
repl, stretch = overrides[key]
|
| 436 |
+
label = f"{repl} ({stretch:.2f}x)"
|
| 437 |
+
pieces.append(f'<span class="token edited" data-word="{key}" data-pron="{repl}" '
|
| 438 |
+
f'data-stretch="{stretch:.2f}" title="{label}">{p}</span>')
|
| 439 |
+
else:
|
| 440 |
+
pieces.append(f'<span class="token" data-word="{key}" data-pron="{p}" '
|
| 441 |
+
f'data-stretch="1.00" title="click to hand-tune">{p}</span>')
|
| 442 |
+
return '<div class="token-row">' + "".join(pieces) + '</div>'
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def speak(sentence, voice_id, level, postfx_preset, mode, seed,
|
| 446 |
+
custom_voice, custom_voice_text, music_path, music_gain_db, overrides_text):
|
| 447 |
+
sentence = (sentence or "").strip()
|
| 448 |
+
level = _safe_int(level)
|
| 449 |
+
if not sentence:
|
| 450 |
+
return None, readout(level, None, None, "type a sentence first"), ""
|
| 451 |
+
overrides = _parse_per_word_overrides(overrides_text or "")
|
| 452 |
+
y, sr, gen_text = _chunked_generate_with_overrides(
|
| 453 |
+
ENGINE, sentence, voice_id, level, int(seed), mode,
|
| 454 |
+
custom_voice or None, custom_voice_text or "", overrides,
|
| 455 |
+
)
|
| 456 |
+
if postfx_preset != "dry":
|
| 457 |
+
y, _ = apply_post_fx(y, sr, preset=postfx_preset)
|
| 458 |
+
if music_path:
|
| 459 |
+
try:
|
| 460 |
+
y, sr = _blend_with_music(y, sr, music_path,
|
| 461 |
+
vocal_gain_db=0.0,
|
| 462 |
+
music_gain_db=float(music_gain_db),
|
| 463 |
+
tempo_lock=True)
|
| 464 |
+
except Exception as e:
|
| 465 |
+
print(f"[blend] failed: {e}; returning dry vocal")
|
| 466 |
+
path = _wav_to_filepath(y, sr)
|
| 467 |
+
readout_text = gen_text if (mode == MODE_GHOST and gen_text and gen_text != sentence) else ""
|
| 468 |
+
return path, readout(level, None, None, f"{mode.lower()} · lv{level}"), readout_text
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def morph(sentence, voice_id, postfx_preset, mode, seed,
|
| 472 |
+
custom_voice, custom_voice_text, music_path, music_gain_db,
|
| 473 |
+
overrides_text, gap_ms: int = 250):
|
| 474 |
+
sentence = (sentence or "").strip()
|
| 475 |
+
if not sentence:
|
| 476 |
+
return None, readout(None, None, None, "type a sentence first"), ""
|
| 477 |
+
overrides = _parse_per_word_overrides(overrides_text or "")
|
| 478 |
+
clips = []
|
| 479 |
+
sr_out = SAMPLE_RATE
|
| 480 |
+
for lv in range(5):
|
| 481 |
+
y, sr, _ = _chunked_generate_with_overrides(
|
| 482 |
+
ENGINE, sentence, voice_id, lv, int(seed), mode,
|
| 483 |
+
custom_voice or None, custom_voice_text or "", overrides,
|
| 484 |
+
)
|
| 485 |
+
sr_out = sr
|
| 486 |
+
clips.append(y)
|
| 487 |
+
morphed = equal_power_concat(clips, sr_out, fade_ms=gap_ms)
|
| 488 |
+
if postfx_preset != "dry":
|
| 489 |
+
morphed, _ = apply_post_fx(morphed, sr_out, preset=postfx_preset)
|
| 490 |
+
if music_path:
|
| 491 |
+
try:
|
| 492 |
+
morphed, sr_out = _blend_with_music(morphed, sr_out, music_path,
|
| 493 |
+
vocal_gain_db=0.0,
|
| 494 |
+
music_gain_db=float(music_gain_db),
|
| 495 |
+
tempo_lock=True)
|
| 496 |
+
except Exception as e:
|
| 497 |
+
print(f"[blend] failed: {e}; returning dry vocal")
|
| 498 |
+
path = _wav_to_filepath(morphed, sr_out)
|
| 499 |
+
return path, readout(None, None, None, f"{mode.lower()} · morphed 0->4"), ""
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# ----- CSS (dreamy pastel theme: half-remembered photograph of dusk) -----
|
| 503 |
+
|
| 504 |
+
CUSTOM_CSS = """
|
| 505 |
+
/* HEAVEN OR LAS VEGAS — direct reference. Long-exposure Christmas lights, deep midnight
|
| 506 |
+
violet background, hot red-orange sun in the lower-right hemisphere, gold light trails
|
| 507 |
+
swooping diagonally. Title in hand-drawn flowing italic script (Pinyon Script ≈ the
|
| 508 |
+
Vaughan Oliver "Heaven or Las Vegas" lettering). Photographic, luminous, analog. */
|
| 509 |
+
|
| 510 |
+
:root {
|
| 511 |
+
--night: #1A0F2D;
|
| 512 |
+
--night-deep: #0E0820;
|
| 513 |
+
--violet: #3D1E54;
|
| 514 |
+
--violet-glow: #5A2F75;
|
| 515 |
+
--sun-core: #FF4D2C;
|
| 516 |
+
--sun-mid: #FF7A3D;
|
| 517 |
+
--sun-halo: rgba(255, 120, 60, 0.55);
|
| 518 |
+
--gold: #FFD66B; /* brighter, more saturated for higher contrast on violet AND on the sun side */
|
| 519 |
+
--gold-bright: #FFE9A3;
|
| 520 |
+
--gold-glow: rgba(255, 214, 107, 0.6);
|
| 521 |
+
--cream: #FFEFC9;
|
| 522 |
+
--cream-mute: #E8D9A0;
|
| 523 |
+
--ink-light: #FFE9B8;
|
| 524 |
+
--ink-mute: #BFAD78;
|
| 525 |
+
--hairline: rgba(255, 214, 107, 0.32);
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
/* BASE: NO opaque ancestor backgrounds (gradio-app + body default to opaque dark and
|
| 529 |
+
would paint over a background layer), and NO background-attachment:fixed (jank on
|
| 530 |
+
scroll, ignored on iOS Safari). The dark base + sun live on the injected #bg-sun
|
| 531 |
+
layer only. Verified: no ancestor sets transform/filter/will-change/contain, so a
|
| 532 |
+
position:fixed child of <body> anchors to the viewport and is never clipped. */
|
| 533 |
+
html, body, gradio-app, .gradio-container, .dark, .light {
|
| 534 |
+
background: transparent !important;
|
| 535 |
+
color: var(--ink-light) !important;
|
| 536 |
+
font-family: 'Cormorant Garamond', Georgia, serif !important;
|
| 537 |
+
min-height: 100vh;
|
| 538 |
+
}
|
| 539 |
+
/* Solid dark on <body> so first paint (before the JS mounts #bg-sun) shows no flash. */
|
| 540 |
+
body { background: var(--night) !important; }
|
| 541 |
+
|
| 542 |
+
/* THE FIXED SUN LAYER: a real <div id="bg-sun"> prepended to <body> by demo.load(js).
|
| 543 |
+
position:fixed + inset:0 => pinned to the viewport, never scrolls, never clipped.
|
| 544 |
+
The sun is a DEFINED ~560px circular ball tucked into the bottom-right corner
|
| 545 |
+
(a `circle 280px` radial-gradient = 560px diameter), centered ~150px past the corner
|
| 546 |
+
so most of the ball is visible like a little sun, fading cleanly to the dark base.
|
| 547 |
+
NO giant viewport-wide wash. translateZ(0) = own GPU layer, no repaint on scroll. */
|
| 548 |
+
#bg-sun {
|
| 549 |
+
position: fixed;
|
| 550 |
+
inset: 0;
|
| 551 |
+
z-index: 0;
|
| 552 |
+
pointer-events: none;
|
| 553 |
+
background:
|
| 554 |
+
radial-gradient(circle 420px at calc(100% - 160px) calc(100% - 160px),
|
| 555 |
+
#FFE7B0 0%,
|
| 556 |
+
#FFB070 14%,
|
| 557 |
+
#FF7A3D 30%,
|
| 558 |
+
var(--sun-core) 46%,
|
| 559 |
+
#C7311A 64%,
|
| 560 |
+
rgba(107, 24, 8, 0.45) 82%,
|
| 561 |
+
transparent 100%),
|
| 562 |
+
var(--night);
|
| 563 |
+
transform: translateZ(0);
|
| 564 |
+
}
|
| 565 |
+
|
| 566 |
+
/* CONTENT sits above the fixed sun layer. */
|
| 567 |
+
.gradio-container {
|
| 568 |
+
background: transparent !important;
|
| 569 |
+
position: relative;
|
| 570 |
+
z-index: 1;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
/* SUN-OVERLAP READABILITY: any gold/cream text gets a dark halo so it pops against
|
| 574 |
+
the bright orange/red of the sun, and a subtle stroke for hard edges. Reads as
|
| 575 |
+
"lit from behind" on dark bg, as "outlined" on bright bg. Plus on the right-half
|
| 576 |
+
accordion labels (which directly overlap the sun corner), apply mix-blend-mode so
|
| 577 |
+
the text auto-inverts as it crosses the sun. */
|
| 578 |
+
.gradio-container button.lg,
|
| 579 |
+
.gradio-container button.primary,
|
| 580 |
+
.gradio-container [data-testid="block-info"],
|
| 581 |
+
.gradio-container input[role="listbox"],
|
| 582 |
+
.gradio-container label.svelte-19qdtil span.svelte-19qdtil,
|
| 583 |
+
.gradio-container .label-wrap,
|
| 584 |
+
.gradio-container .label-wrap span,
|
| 585 |
+
.gradio-container summary,
|
| 586 |
+
.gradio-container [class^="label"],
|
| 587 |
+
#token-preview .token,
|
| 588 |
+
.knob-ticks span {
|
| 589 |
+
text-shadow:
|
| 590 |
+
0 0 1px rgba(0, 0, 0, 0.95),
|
| 591 |
+
0 0 4px rgba(0, 0, 0, 0.85),
|
| 592 |
+
0 0 14px rgba(0, 0, 0, 0.65) !important;
|
| 593 |
+
}
|
| 594 |
+
/* Sun-overlap masking restored: dark semi-opaque pills on labels so the bright sun
|
| 595 |
+
doesn't bleed through into text. Text stays gold; pill blocks the sun behind. */
|
| 596 |
+
.gradio-container .label-wrap,
|
| 597 |
+
.gradio-container summary,
|
| 598 |
+
.gradio-container [data-testid="block-info"] {
|
| 599 |
+
background: rgba(14, 8, 32, 0.78) !important;
|
| 600 |
+
backdrop-filter: blur(6px);
|
| 601 |
+
-webkit-backdrop-filter: blur(6px);
|
| 602 |
+
border: 1px solid rgba(255, 214, 107, 0.22) !important;
|
| 603 |
+
border-radius: 999px !important;
|
| 604 |
+
padding: 10px 22px !important;
|
| 605 |
+
margin: 4px 0 !important;
|
| 606 |
+
}
|
| 607 |
+
.gradio-container [data-testid="block-info"] {
|
| 608 |
+
display: inline-block !important;
|
| 609 |
+
padding: 3px 12px !important;
|
| 610 |
+
font-size: 12px !important;
|
| 611 |
+
margin-bottom: 6px !important;
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
/* (Light trails + film grain layers removed. Flat dark bg + viewport-fixed sun only.) */
|
| 615 |
+
|
| 616 |
+
.gradio-container { max-width: 1040px !important; margin: 0 auto !important; padding: 72px 48px 120px !important; position: relative; z-index: 1; }
|
| 617 |
+
|
| 618 |
+
/* -------------------- HERO -------------------- */
|
| 619 |
+
|
| 620 |
+
#hero { text-align: center; margin-bottom: 72px; position: relative; padding-top: 24px; }
|
| 621 |
+
#hero .wordmark-rule {
|
| 622 |
+
display: inline-block;
|
| 623 |
+
border: 1px solid var(--gold);
|
| 624 |
+
padding: 4px 12px 5px;
|
| 625 |
+
font-family: 'IBM Plex Mono', monospace; font-weight: 400; font-size: 10px;
|
| 626 |
+
color: var(--gold); letter-spacing: 0.36em; text-transform: uppercase;
|
| 627 |
+
margin-bottom: 38px;
|
| 628 |
+
background: rgba(14, 8, 32, 0.55);
|
| 629 |
+
}
|
| 630 |
+
#hero h1 {
|
| 631 |
+
font-family: 'Pinyon Script', cursive;
|
| 632 |
+
font-weight: 400; font-size: 220px;
|
| 633 |
+
letter-spacing: -0.012em; line-height: 0.74; margin: 0;
|
| 634 |
+
color: var(--gold-bright);
|
| 635 |
+
text-shadow:
|
| 636 |
+
0 0 22px rgba(245, 197, 107, 0.62),
|
| 637 |
+
0 0 70px rgba(245, 197, 107, 0.32),
|
| 638 |
+
0 0 130px rgba(245, 197, 107, 0.18),
|
| 639 |
+
0 3px 0 rgba(0, 0, 0, 0.45);
|
| 640 |
+
text-transform: lowercase;
|
| 641 |
+
transform: rotate(-3deg) translateX(-12px);
|
| 642 |
+
display: inline-block;
|
| 643 |
+
}
|
| 644 |
+
#hero .tagline {
|
| 645 |
+
font-family: 'Cormorant Garamond', serif; font-style: italic; font-weight: 300; font-size: 19px;
|
| 646 |
+
color: var(--cream); margin-top: 28px; max-width: 480px;
|
| 647 |
+
margin-left: auto; margin-right: auto;
|
| 648 |
+
line-height: 1.65;
|
| 649 |
+
text-shadow: 0 1px 8px rgba(0, 0, 0, 0.6);
|
| 650 |
+
}
|
| 651 |
+
|
| 652 |
+
/* -------------------- COMPONENTS -------------------- */
|
| 653 |
+
|
| 654 |
+
label, .gr-form > label, span[data-testid="block-info"], .label-wrap, label > span,
|
| 655 |
+
[data-testid="block-label"] {
|
| 656 |
+
color: var(--gold) !important;
|
| 657 |
+
font-family: 'IBM Plex Mono', monospace !important;
|
| 658 |
+
font-style: normal !important;
|
| 659 |
+
font-weight: 400 !important;
|
| 660 |
+
font-size: 10px !important;
|
| 661 |
+
letter-spacing: 0.3em !important;
|
| 662 |
+
text-transform: uppercase !important;
|
| 663 |
+
opacity: 0.85 !important;
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
/* strip the default block chrome from EVERY gradio container so the page reads
|
| 667 |
+
as light + type + air, not stacked UI cards.
|
| 668 |
+
Aggressive: nuke any "block" class background + border + radius. */
|
| 669 |
+
.block, .block-container, .form, .gradio-container > div > div,
|
| 670 |
+
[data-testid="block"], .gr-form, .gr-box,
|
| 671 |
+
.svelte-vt1mxs, .svelte-1ipelgc, .gr-padded,
|
| 672 |
+
[class*=" block"], [class^="block"],
|
| 673 |
+
.svelte-633qhp, .svelte-1plpy97, .svelte-1mwvhlq,
|
| 674 |
+
.gradio-container [class*="container"],
|
| 675 |
+
.gradio-container [class*="form"] {
|
| 676 |
+
background: transparent !important;
|
| 677 |
+
border: none !important;
|
| 678 |
+
box-shadow: none !important;
|
| 679 |
+
border-radius: 0 !important;
|
| 680 |
+
}
|
| 681 |
+
/* leave the slider container with vertical padding so the slider track is visible */
|
| 682 |
+
#dial-slider { padding: 18px 0 24px !important; }
|
| 683 |
+
/* keep accordion content padded so the audio drop boxes don't collapse */
|
| 684 |
+
[data-testid="accordion"] > div:nth-child(2),
|
| 685 |
+
.gradio-container [class*="accordion"] > div:not(:first-child) {
|
| 686 |
+
padding: 16px 0 8px !important;
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
/* Rows: consistent gaps, vertical alignment */
|
| 690 |
+
.gradio-container .gr-row, .gradio-container [class*="row"] {
|
| 691 |
+
gap: 28px !important;
|
| 692 |
+
align-items: end !important;
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
/* lyric textarea — handwriting on light, no card, just a hot gold underline */
|
| 696 |
+
textarea {
|
| 697 |
+
background: transparent !important;
|
| 698 |
+
border: none !important;
|
| 699 |
+
border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important;
|
| 700 |
+
border-radius: 0 !important;
|
| 701 |
+
color: var(--gold-bright) !important;
|
| 702 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 703 |
+
font-style: italic !important;
|
| 704 |
+
font-weight: 300 !important;
|
| 705 |
+
font-size: 32px !important;
|
| 706 |
+
line-height: 1.32 !important;
|
| 707 |
+
padding: 14px 4px 18px !important;
|
| 708 |
+
box-shadow: none !important;
|
| 709 |
+
resize: none !important;
|
| 710 |
+
text-shadow: 0 0 14px rgba(245, 197, 107, 0.32);
|
| 711 |
+
}
|
| 712 |
+
textarea::placeholder { color: var(--cream-mute) !important; opacity: 0.45 !important; font-style: italic !important; }
|
| 713 |
+
textarea:focus { border-bottom-color: var(--gold-bright) !important; outline: none !important; box-shadow: none !important; }
|
| 714 |
+
|
| 715 |
+
/* dropdowns + number inputs — minimal, gold underline only.
|
| 716 |
+
Need to nuke svelte's wrapper containers AND the inner input.
|
| 717 |
+
DO NOT collapse min-height on the slider's .wrap — it would hide the track. */
|
| 718 |
+
input, select, .gr-input, .gr-dropdown, [role="listbox"],
|
| 719 |
+
.secondary-wrap, .container,
|
| 720 |
+
[data-testid="dropdown"], [data-testid="number"] > div {
|
| 721 |
+
background: transparent !important;
|
| 722 |
+
backdrop-filter: none !important;
|
| 723 |
+
border: none !important;
|
| 724 |
+
color: var(--cream) !important;
|
| 725 |
+
border-radius: 0 !important;
|
| 726 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 727 |
+
font-size: 19px !important;
|
| 728 |
+
box-shadow: none !important;
|
| 729 |
+
}
|
| 730 |
+
/* Slider's own .wrap needs to keep its layout intact */
|
| 731 |
+
#dial-slider .wrap.svelte-8epfm4 { background: transparent !important; }
|
| 732 |
+
/* head row (label + number input + reset button) */
|
| 733 |
+
#dial-slider .head.svelte-8epfm4 { display: none !important; }
|
| 734 |
+
/* min/max value labels flanking the track */
|
| 735 |
+
#dial-slider .min_value, #dial-slider .max_value {
|
| 736 |
+
font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 18px;
|
| 737 |
+
color: var(--cream-mute); padding: 0 14px; align-self: center;
|
| 738 |
+
}
|
| 739 |
+
#dial-slider .slider_input_container.svelte-8epfm4 {
|
| 740 |
+
display: flex !important; align-items: center !important; gap: 0 !important;
|
| 741 |
+
padding: 12px 0 !important;
|
| 742 |
+
}
|
| 743 |
+
input, select {
|
| 744 |
+
border-bottom: 1px solid rgba(245, 197, 107, 0.32) !important;
|
| 745 |
+
font-style: italic !important;
|
| 746 |
+
color: var(--gold-bright) !important;
|
| 747 |
+
}
|
| 748 |
+
input[type="text"], input[type="number"], select { padding: 8px 4px !important; }
|
| 749 |
+
input:focus, select:focus {
|
| 750 |
+
border-bottom-color: var(--gold-bright) !important; outline: none !important;
|
| 751 |
+
box-shadow: none !important;
|
| 752 |
+
}
|
| 753 |
+
/* dropdown trigger button (the visible selection) */
|
| 754 |
+
[data-testid="dropdown"] input, .gr-dropdown input {
|
| 755 |
+
background: transparent !important;
|
| 756 |
+
color: var(--gold-bright) !important;
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
/* Dropdown popup panel: dark solid background on the entire popup container plus
|
| 760 |
+
each option, so neither the wrapper gaps nor the options show the page through. */
|
| 761 |
+
ul[role="listbox"],
|
| 762 |
+
[role="listbox"]:not(input),
|
| 763 |
+
.options,
|
| 764 |
+
.options ul,
|
| 765 |
+
.choices,
|
| 766 |
+
ul.choices,
|
| 767 |
+
.svelte-1xfsv4t .options,
|
| 768 |
+
.svelte-1xfsv4t .options ul {
|
| 769 |
+
background: rgba(14, 8, 32, 0.98) !important;
|
| 770 |
+
border: 1px solid var(--gold) !important;
|
| 771 |
+
border-radius: 12px !important;
|
| 772 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6) !important;
|
| 773 |
+
padding: 6px 0 !important;
|
| 774 |
+
z-index: 100 !important;
|
| 775 |
+
}
|
| 776 |
+
ul[role="listbox"] [role="option"],
|
| 777 |
+
[role="option"],
|
| 778 |
+
.options ul li,
|
| 779 |
+
.choices li {
|
| 780 |
+
background: transparent !important; /* the parent's dark covers everything */
|
| 781 |
+
color: var(--cream) !important;
|
| 782 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 783 |
+
font-style: italic !important;
|
| 784 |
+
font-size: 16px !important;
|
| 785 |
+
padding: 8px 18px !important;
|
| 786 |
+
border-radius: 0 !important;
|
| 787 |
+
}
|
| 788 |
+
ul[role="listbox"] [role="option"]:hover,
|
| 789 |
+
[role="option"]:hover,
|
| 790 |
+
.options ul li:hover,
|
| 791 |
+
.choices li:hover {
|
| 792 |
+
background: rgba(245, 197, 107, 0.18) !important;
|
| 793 |
+
color: var(--gold-bright) !important;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
/* mode radio — luminous gold tabs.
|
| 797 |
+
The radio group's outer wrap on its own variant of the svelte class */
|
| 798 |
+
.wrap.svelte-1mwvhlq, [role="radiogroup"] {
|
| 799 |
+
display: inline-flex !important; gap: 0 !important;
|
| 800 |
+
border: 1px solid var(--gold) !important;
|
| 801 |
+
background: transparent !important;
|
| 802 |
+
border-radius: 999px !important;
|
| 803 |
+
padding: 3px !important;
|
| 804 |
+
overflow: hidden;
|
| 805 |
+
}
|
| 806 |
+
|
| 807 |
+
/* kill the boxy gray container around the voice + post-fx dropdowns AND seed number */
|
| 808 |
+
.gradio-container [data-testid="dropdown"],
|
| 809 |
+
.gradio-container [data-testid="number"],
|
| 810 |
+
.gradio-container .form,
|
| 811 |
+
[class^="block"], [class*=" block"] {
|
| 812 |
+
background: transparent !important;
|
| 813 |
+
border: none !important;
|
| 814 |
+
box-shadow: none !important;
|
| 815 |
+
}
|
| 816 |
+
/* Gradio v6 dropdown internals: the .wrap holds the white default; override the whole
|
| 817 |
+
stack down to the bare <input role="listbox">. */
|
| 818 |
+
.gradio-container .wrap.svelte-1xfsv4t,
|
| 819 |
+
.gradio-container .wrap-inner.svelte-1xfsv4t,
|
| 820 |
+
.gradio-container .secondary-wrap.svelte-1xfsv4t {
|
| 821 |
+
background: rgba(14, 8, 32, 0.92) !important;
|
| 822 |
+
border: 1px solid var(--gold) !important;
|
| 823 |
+
border-radius: 999px !important;
|
| 824 |
+
box-shadow: 0 0 18px rgba(255, 214, 107, 0.12) !important;
|
| 825 |
+
}
|
| 826 |
+
.gradio-container input[role="listbox"],
|
| 827 |
+
.gradio-container input.border-none.svelte-1xfsv4t {
|
| 828 |
+
background: transparent !important;
|
| 829 |
+
color: var(--gold-bright) !important;
|
| 830 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 831 |
+
font-style: italic !important;
|
| 832 |
+
font-size: 17px !important;
|
| 833 |
+
padding: 8px 18px !important;
|
| 834 |
+
border: none !important;
|
| 835 |
+
}
|
| 836 |
+
.gradio-container input[role="listbox"]::placeholder { color: var(--cream-mute) !important; opacity: 1 !important; }
|
| 837 |
+
.gradio-container .dropdown-arrow.svelte-loyhyk { fill: var(--gold-bright) !important; }
|
| 838 |
+
|
| 839 |
+
/* Mode radio (Ghost / Tongues) — Gradio v6 svelte-19qdtil */
|
| 840 |
+
.gradio-container label.svelte-19qdtil {
|
| 841 |
+
background: rgba(14, 8, 32, 0.7) !important;
|
| 842 |
+
border: 1px solid var(--gold) !important;
|
| 843 |
+
border-radius: 999px !important;
|
| 844 |
+
padding: 8px 22px !important;
|
| 845 |
+
margin: 0 6px 0 0 !important;
|
| 846 |
+
cursor: pointer !important;
|
| 847 |
+
transition: all 0.22s !important;
|
| 848 |
+
}
|
| 849 |
+
.gradio-container label.svelte-19qdtil span.svelte-19qdtil {
|
| 850 |
+
color: var(--gold-bright) !important;
|
| 851 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 852 |
+
font-style: italic !important;
|
| 853 |
+
font-weight: 500 !important;
|
| 854 |
+
font-size: 16px !important;
|
| 855 |
+
}
|
| 856 |
+
.gradio-container label.svelte-19qdtil:has(input:checked) {
|
| 857 |
+
background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;
|
| 858 |
+
border-color: var(--sun-core) !important;
|
| 859 |
+
box-shadow: 0 0 22px var(--sun-halo) !important;
|
| 860 |
+
}
|
| 861 |
+
.gradio-container label.svelte-19qdtil:has(input:checked) span.svelte-19qdtil {
|
| 862 |
+
color: var(--night-deep) !important;
|
| 863 |
+
}
|
| 864 |
+
/* The native radio dot — hide it; we use the chip-style instead */
|
| 865 |
+
.gradio-container input[type="radio"].svelte-19qdtil { display: none !important; }
|
| 866 |
+
[role="radio"][aria-checked="true"] label,
|
| 867 |
+
input[type="radio"]:checked + label {
|
| 868 |
+
background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;
|
| 869 |
+
color: var(--night-deep) !important;
|
| 870 |
+
box-shadow: 0 0 22px var(--sun-halo), inset 0 1px 0 rgba(255, 220, 180, 0.5) !important;
|
| 871 |
+
}
|
| 872 |
+
|
| 873 |
+
/* buttons — glowing pill on dark, gold borders */
|
| 874 |
+
button.primary, button[variant="primary"], .primary > button, button.lg, .gr-button {
|
| 875 |
+
background: rgba(14, 8, 32, 0.92) !important; /* opaque enough to read over the sun */
|
| 876 |
+
color: var(--gold-bright) !important;
|
| 877 |
+
border: 2px solid var(--gold) !important; /* thicker for visibility */
|
| 878 |
+
padding: 14px 34px !important;
|
| 879 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 880 |
+
font-style: italic !important;
|
| 881 |
+
font-weight: 500 !important; /* heavier weight */
|
| 882 |
+
font-size: 18px !important;
|
| 883 |
+
letter-spacing: 0.02em !important;
|
| 884 |
+
border-radius: 999px !important;
|
| 885 |
+
box-shadow: 0 0 28px rgba(255, 214, 107, 0.32), inset 0 0 14px rgba(255, 214, 107, 0.12) !important;
|
| 886 |
+
transition: all 0.24s !important;
|
| 887 |
+
text-shadow: 0 0 6px rgba(255, 214, 107, 0.55);
|
| 888 |
+
}
|
| 889 |
+
button.primary:hover, button[variant="primary"]:hover, .gr-button:hover {
|
| 890 |
+
background: var(--gold-bright) !important;
|
| 891 |
+
color: var(--night-deep) !important;
|
| 892 |
+
text-shadow: none;
|
| 893 |
+
box-shadow: 0 0 36px var(--gold-glow), inset 0 0 18px rgba(255, 219, 138, 0.4) !important;
|
| 894 |
+
}
|
| 895 |
+
/* second action — Morph — in the hot vermillion / red-orange sun palette */
|
| 896 |
+
.action-row button:nth-of-type(2) {
|
| 897 |
+
border-color: var(--sun-mid) !important;
|
| 898 |
+
color: var(--sun-mid) !important;
|
| 899 |
+
text-shadow: 0 0 10px rgba(255, 122, 61, 0.4);
|
| 900 |
+
box-shadow: 0 0 24px rgba(255, 122, 61, 0.22) !important;
|
| 901 |
+
}
|
| 902 |
+
.action-row button:nth-of-type(2):hover {
|
| 903 |
+
background: linear-gradient(180deg, var(--sun-core), var(--sun-mid)) !important;
|
| 904 |
+
color: var(--night-deep) !important;
|
| 905 |
+
text-shadow: none;
|
| 906 |
+
box-shadow: 0 0 40px var(--sun-halo) !important;
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
/* ALL AUDIO WIDGETS (output "the take" + the voice-clone / backing-track upload
|
| 910 |
+
inputs). Gradio renders these white by default, which clashes with the dark theme
|
| 911 |
+
and reads as a "broken white player". Force the whole stack dark + gold. */
|
| 912 |
+
.gradio-container [data-testid="audio"],
|
| 913 |
+
#audio-out {
|
| 914 |
+
background: rgba(14, 8, 32, 0.88) !important;
|
| 915 |
+
border: 1px solid var(--gold) !important;
|
| 916 |
+
border-radius: 14px !important;
|
| 917 |
+
padding: 14px 16px !important;
|
| 918 |
+
box-shadow: 0 0 20px rgba(255, 214, 107, 0.14) !important;
|
| 919 |
+
margin: 10px 0 !important;
|
| 920 |
+
}
|
| 921 |
+
/* The white "Drop Audio Here / Click to Upload" drop zone inside audio inputs. */
|
| 922 |
+
.gradio-container [data-testid="audio"] .wrap,
|
| 923 |
+
.gradio-container [data-testid="audio"] .upload-container,
|
| 924 |
+
.gradio-container [data-testid="audio"] .file-upload,
|
| 925 |
+
.gradio-container [data-testid="audio"] [class*="upload"],
|
| 926 |
+
.gradio-container [data-testid="audio"] > div {
|
| 927 |
+
background: transparent !important;
|
| 928 |
+
color: var(--cream) !important;
|
| 929 |
+
border-color: var(--hairline) !important;
|
| 930 |
+
}
|
| 931 |
+
/* The white block-label tab (e.g. "the take") that floats top-left. */
|
| 932 |
+
.gradio-container [data-testid="audio"] [data-testid="block-label"],
|
| 933 |
+
.gradio-container [data-testid="block-label"],
|
| 934 |
+
.gradio-container label.svelte-19djge9 {
|
| 935 |
+
background: rgba(14, 8, 32, 0.92) !important;
|
| 936 |
+
color: var(--gold) !important;
|
| 937 |
+
border: 1px solid var(--hairline) !important;
|
| 938 |
+
border-radius: 8px !important;
|
| 939 |
+
}
|
| 940 |
+
.gradio-container [data-testid="block-label"] svg,
|
| 941 |
+
.gradio-container [data-testid="block-label"] span { color: var(--gold) !important; fill: var(--gold) !important; }
|
| 942 |
+
.gradio-container [data-testid="audio"] audio {
|
| 943 |
+
width: 100% !important; height: 46px !important; display: block !important;
|
| 944 |
+
}
|
| 945 |
+
/* The native HTML5 audio control bar: tint it to fit the dark theme. */
|
| 946 |
+
.gradio-container [data-testid="audio"] audio::-webkit-media-controls-panel {
|
| 947 |
+
background: rgba(20, 12, 40, 0.9) !important;
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
/* LIVE PREVIEW TEXTBOX (#ghost-lyric): big, gold-on-dark, italic — it's the
|
| 951 |
+
"see what the voice will say" surface and must be prominent. */
|
| 952 |
+
#ghost-lyric, #ghost-lyric > div, #ghost-lyric .wrap, #ghost-lyric > label > div {
|
| 953 |
+
background: rgba(14, 8, 32, 0.92) !important;
|
| 954 |
+
border: 1px solid var(--gold) !important;
|
| 955 |
+
border-radius: 14px !important;
|
| 956 |
+
}
|
| 957 |
+
#ghost-lyric textarea, #ghost-lyric input {
|
| 958 |
+
background: transparent !important;
|
| 959 |
+
color: var(--gold-bright) !important;
|
| 960 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 961 |
+
font-style: italic !important;
|
| 962 |
+
font-size: 22px !important;
|
| 963 |
+
line-height: 1.4 !important;
|
| 964 |
+
text-align: center !important;
|
| 965 |
+
border: none !important;
|
| 966 |
+
padding: 18px 22px !important;
|
| 967 |
+
min-height: 80px !important;
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
/* THE DIAL — brass knob with vermillion arc and a pointer needle.
|
| 971 |
+
Tick numbers sit in a semicircle above the knob: 0 on the left, 4 on the right. */
|
| 972 |
+
#dial-stack {
|
| 973 |
+
display: flex; flex-direction: column; align-items: center;
|
| 974 |
+
margin: 32px 0 8px;
|
| 975 |
+
}
|
| 976 |
+
.dial-tag {
|
| 977 |
+
font-family: 'IBM Plex Mono', monospace; font-size: 10px;
|
| 978 |
+
color: var(--gold); letter-spacing: 0.34em; text-transform: uppercase;
|
| 979 |
+
opacity: 0.8; margin-bottom: 12px; /* gap below the tag; ticks ride above the knob on their own */
|
| 980 |
+
}
|
| 981 |
+
.knob-stage {
|
| 982 |
+
position: relative;
|
| 983 |
+
width: 420px; height: 400px; /* big enough to hold ticks at radius 156 above and around the knob */
|
| 984 |
+
display: flex; align-items: flex-end; justify-content: center;
|
| 985 |
+
}
|
| 986 |
+
/* Tick semicircle hugging the brass ring of the knob.
|
| 987 |
+
Positioned with its origin AT the knob center, so the JS polar coords are direct. */
|
| 988 |
+
.knob-ticks {
|
| 989 |
+
position: absolute;
|
| 990 |
+
left: 50%;
|
| 991 |
+
bottom: 156px; /* knob-wrap margin-bottom (16) + knob radius (140) = knob center */
|
| 992 |
+
width: 0; height: 0; pointer-events: none;
|
| 993 |
+
}
|
| 994 |
+
.knob-ticks span {
|
| 995 |
+
position: absolute;
|
| 996 |
+
left: 0; top: 0;
|
| 997 |
+
transform: translate(calc(var(--tx) - 50%), calc(var(--ty) - 50%));
|
| 998 |
+
display: flex; flex-direction: column; align-items: center;
|
| 999 |
+
font-family: 'Cormorant Garamond', serif; font-style: italic;
|
| 1000 |
+
font-size: 24px; color: var(--cream-mute);
|
| 1001 |
+
cursor: pointer; pointer-events: auto;
|
| 1002 |
+
transition: color 0.22s, transform 0.22s;
|
| 1003 |
+
line-height: 1;
|
| 1004 |
+
}
|
| 1005 |
+
.knob-ticks span small {
|
| 1006 |
+
font-family: 'IBM Plex Mono', monospace; font-style: normal;
|
| 1007 |
+
font-size: 8px; color: var(--cream-mute); opacity: 0.55;
|
| 1008 |
+
letter-spacing: 0.22em; text-transform: uppercase;
|
| 1009 |
+
margin-top: 4px; max-width: 80px; text-align: center;
|
| 1010 |
+
}
|
| 1011 |
+
.knob-ticks span.active {
|
| 1012 |
+
color: var(--gold-bright); font-weight: 500; font-style: normal;
|
| 1013 |
+
text-shadow: 0 0 12px var(--gold-glow);
|
| 1014 |
+
transform: translate(calc(var(--tx) - 50%), calc(var(--ty) - 50%)) scale(1.22);
|
| 1015 |
+
}
|
| 1016 |
+
.knob-ticks span:hover { color: var(--gold-bright); }
|
| 1017 |
+
|
| 1018 |
+
/* Old-tech knob: knurled outer ring + flat dark face + thin cream indicator line.
|
| 1019 |
+
Inspired by 1970s hi-fi tuner knobs (Marantz, Moog, Bakelite). */
|
| 1020 |
+
.knob-wrap {
|
| 1021 |
+
position: relative;
|
| 1022 |
+
width: 280px; height: 280px;
|
| 1023 |
+
display: flex; align-items: center; justify-content: center;
|
| 1024 |
+
margin-bottom: 16px;
|
| 1025 |
+
filter: drop-shadow(0 14px 28px rgba(0,0,0,0.65));
|
| 1026 |
+
}
|
| 1027 |
+
.knob {
|
| 1028 |
+
position: relative; z-index: 2;
|
| 1029 |
+
width: 280px; height: 280px; border-radius: 50%;
|
| 1030 |
+
cursor: grab; outline: none;
|
| 1031 |
+
/* The flat face: warm walnut / dark Bakelite */
|
| 1032 |
+
background:
|
| 1033 |
+
radial-gradient(circle at 38% 32%,
|
| 1034 |
+
rgba(255, 220, 175, 0.18) 0%,
|
| 1035 |
+
rgba(255, 220, 175, 0.0) 28%),
|
| 1036 |
+
radial-gradient(circle at 50% 50%,
|
| 1037 |
+
#2C1B0E 0%, #1B0F08 70%, #100804 100%);
|
| 1038 |
+
box-shadow:
|
| 1039 |
+
inset 0 0 0 1px rgba(255, 220, 180, 0.1),
|
| 1040 |
+
inset 0 -6px 12px rgba(0,0,0,0.55),
|
| 1041 |
+
inset 0 4px 10px rgba(255, 220, 180, 0.08);
|
| 1042 |
+
touch-action: none; user-select: none;
|
| 1043 |
+
transition: filter 0.22s;
|
| 1044 |
+
}
|
| 1045 |
+
/* Knurled rim — fine repeating ridges in brass */
|
| 1046 |
+
.knob::before {
|
| 1047 |
+
content: ''; position: absolute; inset: 0; border-radius: 50%; pointer-events: none;
|
| 1048 |
+
background: repeating-conic-gradient(
|
| 1049 |
+
from 0deg,
|
| 1050 |
+
#B89968 0deg 2deg,
|
| 1051 |
+
#4A3618 2deg 4deg,
|
| 1052 |
+
#8B6E3A 4deg 6deg
|
| 1053 |
+
);
|
| 1054 |
+
-webkit-mask: radial-gradient(circle, transparent 102px, #000 103px, #000 136px, transparent 137px);
|
| 1055 |
+
mask: radial-gradient(circle, transparent 102px, #000 103px, #000 136px, transparent 137px);
|
| 1056 |
+
filter: brightness(0.92);
|
| 1057 |
+
}
|
| 1058 |
+
/* Subtle inner highlight ring between knurled rim and flat face */
|
| 1059 |
+
.knob::after {
|
| 1060 |
+
content: ''; position: absolute; inset: 12px; border-radius: 50%; pointer-events: none;
|
| 1061 |
+
box-shadow:
|
| 1062 |
+
inset 0 0 0 1px rgba(255, 220, 180, 0.18),
|
| 1063 |
+
inset 0 0 18px rgba(0,0,0,0.45);
|
| 1064 |
+
}
|
| 1065 |
+
.knob:focus-visible, .knob.dragging {
|
| 1066 |
+
filter: brightness(1.04);
|
| 1067 |
+
}
|
| 1068 |
+
.knob.dragging { cursor: grabbing; }
|
| 1069 |
+
/* Faint gold arc OUTSIDE the knurled rim showing dial position.
|
| 1070 |
+
Knob radius 140, rim outer ~136, so the arc sits at radius 144..150. The element
|
| 1071 |
+
extends to inset:-16px so the mask circle fits. */
|
| 1072 |
+
.knob-arc {
|
| 1073 |
+
position: absolute; z-index: 1; inset: -16px; border-radius: 50%; pointer-events: none;
|
| 1074 |
+
background: conic-gradient(from 270deg,
|
| 1075 |
+
var(--gold) 0deg,
|
| 1076 |
+
var(--gold-bright) var(--arc-deg, 0deg),
|
| 1077 |
+
transparent var(--arc-deg, 0deg) 180deg,
|
| 1078 |
+
transparent 360deg);
|
| 1079 |
+
-webkit-mask: radial-gradient(circle, transparent 144px, #000 145px, #000 152px, transparent 153px);
|
| 1080 |
+
mask: radial-gradient(circle, transparent 144px, #000 145px, #000 152px, transparent 153px);
|
| 1081 |
+
filter: drop-shadow(0 0 8px var(--gold-glow));
|
| 1082 |
+
opacity: 0.9;
|
| 1083 |
+
}
|
| 1084 |
+
/* THE INDICATOR LINE — a thin cream line painted on the knob face, from center
|
| 1085 |
+
to the edge of the knurled rim. Rotates with the knob value. */
|
| 1086 |
+
.knob-pointer {
|
| 1087 |
+
position: absolute; z-index: 4;
|
| 1088 |
+
left: 50%; top: 18px;
|
| 1089 |
+
width: 5px; height: 96px;
|
| 1090 |
+
margin-left: -2.5px;
|
| 1091 |
+
background: linear-gradient(180deg, #FFF6D8 0%, #EFDBA8 80%, rgba(239, 219, 168, 0.0) 100%);
|
| 1092 |
+
border-radius: 2px;
|
| 1093 |
+
box-shadow:
|
| 1094 |
+
0 0 8px rgba(255, 240, 200, 0.7),
|
| 1095 |
+
0 0 18px rgba(255, 240, 200, 0.35),
|
| 1096 |
+
inset 0 1px 0 rgba(255, 255, 240, 0.85);
|
| 1097 |
+
transform-origin: 50% 122px; /* pivot at the knob center (140 - 18) */
|
| 1098 |
+
transform: rotate(var(--knob-angle, -90deg));
|
| 1099 |
+
pointer-events: none;
|
| 1100 |
+
transition: transform 0.24s cubic-bezier(.34,1.36,.4,1);
|
| 1101 |
+
}
|
| 1102 |
+
.knob-pin { display: none; }
|
| 1103 |
+
|
| 1104 |
+
/* Hide the actual gradio slider — the knob drives it via JS */
|
| 1105 |
+
#dial-slider { display: none !important; }
|
| 1106 |
+
#dial-slider .head, #dial-slider .slider_input_container { display: none !important; }
|
| 1107 |
+
|
| 1108 |
+
/* readout — newspaper-strip-like, gold rule */
|
| 1109 |
+
.readout { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; margin-top: 24px;
|
| 1110 |
+
border-top: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); padding: 16px 0; }
|
| 1111 |
+
.readout-cell { background: transparent; padding: 4px 14px; text-align: left; border-right: 1px solid var(--hairline); }
|
| 1112 |
+
.readout-cell:last-child { border-right: none; }
|
| 1113 |
+
.readout-label { font-family: 'IBM Plex Mono', monospace; font-size: 9px; letter-spacing: 0.32em; color: var(--gold); margin-bottom: 6px; text-transform: uppercase; opacity: 0.7; }
|
| 1114 |
+
.readout-val { font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 22px; font-weight: 400; color: var(--cream); letter-spacing: -0.01em; }
|
| 1115 |
+
|
| 1116 |
+
/* ghost lyric — luminous pulled-quote in gold */
|
| 1117 |
+
.ghost-lyric textarea, [data-testid="textbox"]:not(:first-of-type) textarea {
|
| 1118 |
+
font-family: 'Cormorant Garamond', serif !important;
|
| 1119 |
+
font-style: italic !important;
|
| 1120 |
+
font-size: 22px !important;
|
| 1121 |
+
color: var(--gold-bright) !important;
|
| 1122 |
+
border: none !important;
|
| 1123 |
+
border-left: 2px solid var(--gold) !important;
|
| 1124 |
+
background: rgba(245, 197, 107, 0.04) !important;
|
| 1125 |
+
padding: 14px 0 14px 22px !important;
|
| 1126 |
+
border-radius: 0 !important;
|
| 1127 |
+
text-shadow: 0 0 10px rgba(245, 197, 107, 0.3);
|
| 1128 |
+
}
|
| 1129 |
+
|
| 1130 |
+
#footer {
|
| 1131 |
+
margin-top: 88px; padding-top: 28px;
|
| 1132 |
+
border-top: 1px solid var(--hairline);
|
| 1133 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 1134 |
+
font-size: 10px; color: var(--cream-mute); letter-spacing: 0.26em;
|
| 1135 |
+
text-transform: uppercase;
|
| 1136 |
+
line-height: 1.9;
|
| 1137 |
+
display: grid; grid-template-columns: 1fr auto; gap: 24px;
|
| 1138 |
+
opacity: 0.7;
|
| 1139 |
+
}
|
| 1140 |
+
|
| 1141 |
+
/* per-word token chips + inline editor */
|
| 1142 |
+
#token-preview { margin-top: 6px; min-height: 40px; }
|
| 1143 |
+
.token-row {
|
| 1144 |
+
line-height: 2.2; font-family: 'Cormorant Garamond', serif; font-style: italic;
|
| 1145 |
+
font-size: 22px; color: var(--cream);
|
| 1146 |
+
}
|
| 1147 |
+
.token {
|
| 1148 |
+
cursor: pointer; padding: 2px 4px; border-radius: 4px;
|
| 1149 |
+
border-bottom: 1px dashed rgba(245, 197, 107, 0.32);
|
| 1150 |
+
transition: background 0.18s, color 0.18s;
|
| 1151 |
+
}
|
| 1152 |
+
.token:hover { background: rgba(245, 197, 107, 0.14); color: var(--gold-bright); }
|
| 1153 |
+
.token.edited {
|
| 1154 |
+
color: var(--gold-bright);
|
| 1155 |
+
background: rgba(255, 80, 40, 0.18);
|
| 1156 |
+
border-bottom: 1px solid var(--vermillion);
|
| 1157 |
+
text-shadow: 0 0 12px var(--gold-glow);
|
| 1158 |
+
}
|
| 1159 |
+
.token-editor {
|
| 1160 |
+
display: inline-block; vertical-align: middle;
|
| 1161 |
+
background: rgba(14, 8, 32, 0.95); border: 1px solid var(--gold);
|
| 1162 |
+
border-radius: 8px; padding: 12px 14px; margin: 6px 8px;
|
| 1163 |
+
box-shadow: 0 8px 28px rgba(0,0,0,0.5), 0 0 0 2px rgba(245, 197, 107, 0.12);
|
| 1164 |
+
color: var(--cream);
|
| 1165 |
+
font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 15px;
|
| 1166 |
+
z-index: 12; position: relative;
|
| 1167 |
+
}
|
| 1168 |
+
.token-editor .te-row { display: flex; align-items: center; gap: 10px; margin: 6px 0; }
|
| 1169 |
+
.token-editor .te-head { font-style: italic; color: var(--gold); border-bottom: 1px solid var(--hairline); padding-bottom: 6px; margin-bottom: 8px; }
|
| 1170 |
+
.token-editor .te-head b { color: var(--gold-bright); font-weight: 500; }
|
| 1171 |
+
.token-editor input.te-pron {
|
| 1172 |
+
background: transparent !important; border: none !important;
|
| 1173 |
+
border-bottom: 1px solid rgba(245, 197, 107, 0.4) !important;
|
| 1174 |
+
color: var(--gold-bright) !important; font-style: italic !important;
|
| 1175 |
+
font-size: 16px !important; padding: 4px 6px !important; min-width: 130px;
|
| 1176 |
+
}
|
| 1177 |
+
.token-editor input.te-stretch {
|
| 1178 |
+
-webkit-appearance: none; appearance: none;
|
| 1179 |
+
width: 150px; height: 3px; background: rgba(245, 197, 107, 0.32);
|
| 1180 |
+
border-radius: 999px; outline: none; cursor: pointer;
|
| 1181 |
+
box-shadow: 0 0 6px var(--gold-glow);
|
| 1182 |
+
}
|
| 1183 |
+
.token-editor input.te-stretch::-webkit-slider-thumb {
|
| 1184 |
+
-webkit-appearance: none; appearance: none;
|
| 1185 |
+
width: 14px; height: 14px; border-radius: 50%;
|
| 1186 |
+
background: radial-gradient(circle at 30% 30%, var(--sun-mid), var(--sun-core));
|
| 1187 |
+
box-shadow: 0 0 8px var(--sun-halo);
|
| 1188 |
+
cursor: grab;
|
| 1189 |
+
}
|
| 1190 |
+
.token-editor .te-val { color: var(--cream-mute); min-width: 50px; text-align: right; font-size: 13px; }
|
| 1191 |
+
.token-editor .te-buttons { justify-content: flex-end; }
|
| 1192 |
+
.token-editor button {
|
| 1193 |
+
background: transparent; border: 1px solid rgba(245, 197, 107, 0.42);
|
| 1194 |
+
color: var(--cream); padding: 4px 14px; border-radius: 999px;
|
| 1195 |
+
font-family: 'Cormorant Garamond', serif; font-style: italic; font-size: 14px;
|
| 1196 |
+
cursor: pointer; transition: all 0.18s;
|
| 1197 |
+
}
|
| 1198 |
+
.token-editor button:hover { background: rgba(245, 197, 107, 0.18); color: var(--gold-bright); }
|
| 1199 |
+
.token-editor button.te-apply { border-color: var(--vermillion); color: var(--vermillion); }
|
| 1200 |
+
.token-editor button.te-apply:hover { background: var(--vermillion); color: var(--paper); }
|
| 1201 |
+
|
| 1202 |
+
.dial-label { display: none; }
|
| 1203 |
+
|
| 1204 |
+
/* hide the standard gradio header / footer chrome */
|
| 1205 |
+
.show-api, footer.svelte-mpyp5e, gradio-app > footer { display: none !important; }
|
| 1206 |
+
|
| 1207 |
+
@media (max-width: 720px) {
|
| 1208 |
+
#hero h1 { font-size: 88px !important; }
|
| 1209 |
+
.gradio-container { padding: 36px 18px 64px !important; }
|
| 1210 |
+
textarea { font-size: 20px !important; }
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
"""
|
| 1214 |
+
|
| 1215 |
+
|
| 1216 |
+
_THEME = gr.themes.Base(primary_hue=gr.themes.colors.pink, neutral_hue=gr.themes.colors.slate).set(
|
| 1217 |
+
body_background_fill="*neutral_950", block_background_fill="*neutral_900",
|
| 1218 |
+
)
|
| 1219 |
+
|
| 1220 |
+
|
| 1221 |
+
# ----- circular knob widget (gr.HTML + html_template + js_on_load + trigger('change'))
|
| 1222 |
+
# Requires Gradio >= 6.9 for arbitrary event-name triggers (we pin >= 6.10 in requirements.txt).
|
| 1223 |
+
# Pattern is from gradio.app/guides/custom-HTML-components.
|
| 1224 |
+
|
| 1225 |
+
KNOB_HTML = """
|
| 1226 |
+
<style>
|
| 1227 |
+
#dial-knob { padding: 28px 0 12px 0; display: flex; flex-direction: column; align-items: center; }
|
| 1228 |
+
.knob-stage {
|
| 1229 |
+
position: relative; width: 280px; height: 280px;
|
| 1230 |
+
display: flex; align-items: center; justify-content: center;
|
| 1231 |
+
}
|
| 1232 |
+
/* outer brass ring */
|
| 1233 |
+
.knob-stage::before {
|
| 1234 |
+
content: ''; position: absolute; inset: 0; border-radius: 50%;
|
| 1235 |
+
background: conic-gradient(from 0deg,
|
| 1236 |
+
#B89968, #6B5328, #D6B888, #A8884D, #6B5328, #C6A468, #B89968);
|
| 1237 |
+
filter: blur(0.5px);
|
| 1238 |
+
}
|
| 1239 |
+
.knob-stage::after {
|
| 1240 |
+
content: ''; position: absolute; inset: 10px; border-radius: 50%;
|
| 1241 |
+
background: var(--paper);
|
| 1242 |
+
box-shadow:
|
| 1243 |
+
inset 0 0 0 1px rgba(42,31,45,0.18),
|
| 1244 |
+
0 18px 32px -16px rgba(42,31,45,0.42),
|
| 1245 |
+
0 2px 0 rgba(255,255,255,0.6) inset;
|
| 1246 |
+
}
|
| 1247 |
+
.knob-host {
|
| 1248 |
+
position: relative; z-index: 2; width: 192px; height: 192px;
|
| 1249 |
+
border-radius: 50%; cursor: grab; outline: none;
|
| 1250 |
+
background:
|
| 1251 |
+
radial-gradient(circle at 32% 28%, #FFF1D8 0%, #E8C7A0 30%, #B89968 64%, #6B5328 96%);
|
| 1252 |
+
box-shadow:
|
| 1253 |
+
inset 0 -8px 18px rgba(42,31,45,0.32),
|
| 1254 |
+
inset 0 2px 6px rgba(255,255,255,0.45),
|
| 1255 |
+
0 6px 16px rgba(42,31,45,0.22);
|
| 1256 |
+
touch-action: none; user-select: none;
|
| 1257 |
+
transition: box-shadow 0.18s, filter 0.22s;
|
| 1258 |
+
}
|
| 1259 |
+
.knob-host:focus-visible, .knob-host.dragging {
|
| 1260 |
+
box-shadow:
|
| 1261 |
+
inset 0 -8px 18px rgba(42,31,45,0.32),
|
| 1262 |
+
inset 0 2px 6px rgba(255,255,255,0.55),
|
| 1263 |
+
0 0 0 2px var(--vermillion),
|
| 1264 |
+
0 8px 24px var(--vermillion-glow);
|
| 1265 |
+
filter: saturate(1.08);
|
| 1266 |
+
}
|
| 1267 |
+
.knob-host.dragging { cursor: grabbing; }
|
| 1268 |
+
/* vermillion arc */
|
| 1269 |
+
.knob-arc {
|
| 1270 |
+
position: absolute; z-index: 3; inset: -16px; border-radius: 50%; pointer-events: none;
|
| 1271 |
+
background: conic-gradient(
|
| 1272 |
+
from 225deg,
|
| 1273 |
+
var(--vermillion) 0deg,
|
| 1274 |
+
var(--vermillion) var(--arc-deg, 0deg),
|
| 1275 |
+
transparent var(--arc-deg, 0deg) 270deg,
|
| 1276 |
+
transparent 360deg
|
| 1277 |
+
);
|
| 1278 |
+
-webkit-mask: radial-gradient(circle, transparent 116px, #000 117px, #000 124px, transparent 125px);
|
| 1279 |
+
mask: radial-gradient(circle, transparent 116px, #000 117px, #000 124px, transparent 125px);
|
| 1280 |
+
filter: drop-shadow(0 0 8px var(--vermillion-glow));
|
| 1281 |
+
}
|
| 1282 |
+
/* indicator — a tiny vermillion bar with subtle glow */
|
| 1283 |
+
.knob-indicator {
|
| 1284 |
+
position: absolute; z-index: 4; left: 50%; top: 22px;
|
| 1285 |
+
width: 4px; height: 28px; margin-left: -2px;
|
| 1286 |
+
background: var(--vermillion); border-radius: 2px;
|
| 1287 |
+
transform-origin: 50% 74px;
|
| 1288 |
+
transform: rotate(var(--knob-angle, -135deg));
|
| 1289 |
+
box-shadow: 0 0 12px var(--vermillion-glow);
|
| 1290 |
+
pointer-events: none;
|
| 1291 |
+
transition: transform 0.16s cubic-bezier(.34,1.36,.4,1);
|
| 1292 |
+
}
|
| 1293 |
+
/* the center numeral — italic Fraunces in ink */
|
| 1294 |
+
.knob-label {
|
| 1295 |
+
position: absolute; z-index: 5; inset: 0;
|
| 1296 |
+
display: flex; align-items: center; justify-content: center;
|
| 1297 |
+
font-family: 'Fraunces', serif; font-style: italic; font-weight: 300;
|
| 1298 |
+
font-size: 92px; color: var(--ink);
|
| 1299 |
+
letter-spacing: -0.04em; pointer-events: none;
|
| 1300 |
+
text-shadow: 0 1px 0 rgba(255, 240, 220, 0.5), 0 -1px 0 rgba(42, 31, 45, 0.12);
|
| 1301 |
+
}
|
| 1302 |
+
/* ticks — italic editorial numerals around the knob */
|
| 1303 |
+
.knob-ticks {
|
| 1304 |
+
display: flex; justify-content: space-between; width: 320px; margin: 28px auto 0;
|
| 1305 |
+
font-family: 'Fraunces', serif; font-style: italic; font-weight: 300;
|
| 1306 |
+
font-size: 14px; color: var(--ink-quiet);
|
| 1307 |
+
padding: 0 18px;
|
| 1308 |
+
}
|
| 1309 |
+
.knob-ticks span {
|
| 1310 |
+
display: inline-flex; flex-direction: column; align-items: center; gap: 4px;
|
| 1311 |
+
cursor: pointer; transition: color 0.18s;
|
| 1312 |
+
}
|
| 1313 |
+
.knob-ticks span::before {
|
| 1314 |
+
content: ''; width: 1px; height: 8px; background: var(--ink-quiet);
|
| 1315 |
+
transition: background 0.18s;
|
| 1316 |
+
}
|
| 1317 |
+
.knob-ticks span.active { color: var(--vermillion); font-weight: 400; font-style: normal; }
|
| 1318 |
+
.knob-ticks span.active::before { background: var(--vermillion); height: 12px; }
|
| 1319 |
+
.knob-caption {
|
| 1320 |
+
font-family: 'IBM Plex Mono', monospace; font-size: 10px;
|
| 1321 |
+
letter-spacing: 0.34em; color: var(--ink-quiet);
|
| 1322 |
+
text-transform: uppercase; margin-top: 12px;
|
| 1323 |
+
}
|
| 1324 |
+
</style>
|
| 1325 |
+
<div id="dial-knob">
|
| 1326 |
+
<div class="knob-stage">
|
| 1327 |
+
<div class="knob-host" id="dial-knob-host" tabindex="0" role="slider"
|
| 1328 |
+
aria-valuemin="0" aria-valuemax="4" aria-valuenow="${value}" aria-label="dial">
|
| 1329 |
+
<div class="knob-arc"></div>
|
| 1330 |
+
<div class="knob-indicator"></div>
|
| 1331 |
+
<div class="knob-label">${value}</div>
|
| 1332 |
+
</div>
|
| 1333 |
+
</div>
|
| 1334 |
+
<div class="knob-ticks">
|
| 1335 |
+
<span data-lv="0">0</span><span data-lv="1">1</span><span data-lv="2">2</span><span data-lv="3">3</span><span data-lv="4">4</span>
|
| 1336 |
+
</div>
|
| 1337 |
+
<div class="knob-caption">turn · drag · scroll · arrow keys</div>
|
| 1338 |
+
</div>
|
| 1339 |
+
"""
|
| 1340 |
+
|
| 1341 |
+
KNOB_JS = """
|
| 1342 |
+
(element, props) => {
|
| 1343 |
+
const host = element.querySelector('.knob-host');
|
| 1344 |
+
if (!host) return;
|
| 1345 |
+
const lab = element.querySelector('.knob-label');
|
| 1346 |
+
const ticks = Array.from(element.querySelectorAll('.knob-ticks span'));
|
| 1347 |
+
const LEVELS = 5;
|
| 1348 |
+
const ANGLE_MIN = -135, ANGLE_MAX = 135;
|
| 1349 |
+
let level = Math.max(0, Math.min(LEVELS - 1, Math.round(Number(props.value) || 0)));
|
| 1350 |
+
|
| 1351 |
+
const render = (lv) => {
|
| 1352 |
+
const frac = lv / (LEVELS - 1);
|
| 1353 |
+
const ang = ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * frac;
|
| 1354 |
+
const arc = (ANGLE_MAX - ANGLE_MIN) * frac;
|
| 1355 |
+
host.style.setProperty('--knob-angle', ang + 'deg');
|
| 1356 |
+
host.style.setProperty('--arc-deg', arc + 'deg');
|
| 1357 |
+
lab.textContent = lv;
|
| 1358 |
+
host.setAttribute('aria-valuenow', lv);
|
| 1359 |
+
ticks.forEach(t => t.classList.toggle('active', Number(t.dataset.lv) === lv));
|
| 1360 |
+
};
|
| 1361 |
+
|
| 1362 |
+
const setLevel = (lv, fire) => {
|
| 1363 |
+
lv = Math.max(0, Math.min(LEVELS - 1, Math.round(lv)));
|
| 1364 |
+
if (lv === level && !fire) return;
|
| 1365 |
+
level = lv;
|
| 1366 |
+
render(level);
|
| 1367 |
+
if (fire) { props.value = level; trigger('change'); }
|
| 1368 |
+
};
|
| 1369 |
+
render(level);
|
| 1370 |
+
|
| 1371 |
+
let dragging = false, startY = 0, startLv = 0;
|
| 1372 |
+
host.addEventListener('pointerdown', (e) => {
|
| 1373 |
+
dragging = true; startY = e.clientY; startLv = level;
|
| 1374 |
+
host.setPointerCapture(e.pointerId); host.classList.add('dragging');
|
| 1375 |
+
});
|
| 1376 |
+
host.addEventListener('pointermove', (e) => {
|
| 1377 |
+
if (!dragging) return;
|
| 1378 |
+
const dy = startY - e.clientY; // drag up -> louder
|
| 1379 |
+
setLevel(startLv + dy / 40, true);
|
| 1380 |
+
});
|
| 1381 |
+
const endDrag = (e) => {
|
| 1382 |
+
if (!dragging) return;
|
| 1383 |
+
dragging = false;
|
| 1384 |
+
try { host.releasePointerCapture(e.pointerId); } catch (_) {}
|
| 1385 |
+
host.classList.remove('dragging');
|
| 1386 |
+
};
|
| 1387 |
+
host.addEventListener('pointerup', endDrag);
|
| 1388 |
+
host.addEventListener('pointercancel', endDrag);
|
| 1389 |
+
host.addEventListener('wheel', (e) => {
|
| 1390 |
+
e.preventDefault();
|
| 1391 |
+
setLevel(level + (e.deltaY < 0 ? 1 : -1), true);
|
| 1392 |
+
}, { passive: false });
|
| 1393 |
+
host.addEventListener('keydown', (e) => {
|
| 1394 |
+
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); setLevel(level + 1, true); }
|
| 1395 |
+
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); setLevel(level - 1, true); }
|
| 1396 |
+
else if (e.key === 'Home') { e.preventDefault(); setLevel(0, true); }
|
| 1397 |
+
else if (e.key === 'End') { e.preventDefault(); setLevel(LEVELS - 1, true); }
|
| 1398 |
+
});
|
| 1399 |
+
ticks.forEach(t => t.addEventListener('click', () => setLevel(Number(t.dataset.lv), true)));
|
| 1400 |
+
}
|
| 1401 |
+
"""
|
| 1402 |
+
|
| 1403 |
+
STYLE_PRESETS = {
|
| 1404 |
+
"dreamy": "Goodman/Samarin glossolalia palette",
|
| 1405 |
+
"hopelandic": "glide-led, high-front vowel chains (Sigur Rós register)",
|
| 1406 |
+
"fraser": "m/n/l onsets, open back vowels (Cocteau Twins register)",
|
| 1407 |
+
}
|
| 1408 |
+
|
| 1409 |
+
with gr.Blocks(title="Glossolalia Dial") as demo:
|
| 1410 |
+
# Inject fonts via <link> — Gradio's CSS-in-JS strips @import, so the stylesheet
|
| 1411 |
+
# rule has to land in the document head as a tag, not in the CSS string.
|
| 1412 |
+
gr.HTML(
|
| 1413 |
+
"""
|
| 1414 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 1415 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 1416 |
+
<link href="https://fonts.googleapis.com/css2?family=Pinyon+Script&family=Cormorant+Garamond:ital,wght@0,300;0,400;0,500;1,300;1,400&family=IBM+Plex+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
| 1417 |
+
<div id="hero">
|
| 1418 |
+
<h1>glossolalia</h1>
|
| 1419 |
+
<p class="tagline">type a lyric. pick a voice. turn the dial.<br>
|
| 1420 |
+
hear it dissolve into wordless tongues, in your own voice.</p>
|
| 1421 |
+
</div>
|
| 1422 |
+
"""
|
| 1423 |
+
)
|
| 1424 |
+
|
| 1425 |
+
# lyric — magazine pull-quote spanning full width
|
| 1426 |
+
sentence = gr.Textbox(label="the lyric", value=DEFAULT_TEXT, lines=2,
|
| 1427 |
+
placeholder="anything; the dial will dissolve it",
|
| 1428 |
+
elem_id="lyric-input")
|
| 1429 |
+
|
| 1430 |
+
# voice + mode in a quiet two-up
|
| 1431 |
+
with gr.Row():
|
| 1432 |
+
voice = gr.Dropdown([(v["name"], k) for k, v in VOICE_PRESETS.items()],
|
| 1433 |
+
value=DEFAULT_VOICE, label="the voice", scale=1)
|
| 1434 |
+
mode = gr.Radio(choices=list(MODES), value=MODE_TONGUES,
|
| 1435 |
+
label="the path", scale=1)
|
| 1436 |
+
|
| 1437 |
+
# custom voice — record or upload a 10-sec clip; F5-TTS clones it
|
| 1438 |
+
with gr.Accordion("🎤 Clone your own voice (record or upload 6-12 sec)",
|
| 1439 |
+
open=False, elem_id="custom-voice-accord"):
|
| 1440 |
+
with gr.Row():
|
| 1441 |
+
custom_voice = gr.Audio(
|
| 1442 |
+
sources=["upload", "microphone"],
|
| 1443 |
+
type="filepath",
|
| 1444 |
+
label="reference clip (clean speech, no background music, 6-12 sec)",
|
| 1445 |
+
scale=2,
|
| 1446 |
+
)
|
| 1447 |
+
custom_voice_text = gr.Textbox(
|
| 1448 |
+
label="(optional) transcript of the clip",
|
| 1449 |
+
placeholder="leave empty to auto-transcribe (Whisper)",
|
| 1450 |
+
lines=2,
|
| 1451 |
+
scale=1,
|
| 1452 |
+
)
|
| 1453 |
+
|
| 1454 |
+
# background music — upload an instrumental; we tempo-lock + mix the vocal over it
|
| 1455 |
+
with gr.Accordion("🎵 Add background music (mix a backing track under the voice)",
|
| 1456 |
+
open=False, elem_id="music-accord"):
|
| 1457 |
+
with gr.Row():
|
| 1458 |
+
music_input = gr.Audio(
|
| 1459 |
+
sources=["upload"],
|
| 1460 |
+
type="filepath",
|
| 1461 |
+
label="backing track (instrumental, any length, any tempo)",
|
| 1462 |
+
scale=2,
|
| 1463 |
+
)
|
| 1464 |
+
music_gain = gr.Slider(
|
| 1465 |
+
-24, 6, value=-8, step=1,
|
| 1466 |
+
label="music level (dB)",
|
| 1467 |
+
scale=1,
|
| 1468 |
+
)
|
| 1469 |
+
|
| 1470 |
+
# per-word overrides — stretch + pronunciation per individual word.
|
| 1471 |
+
# Token preview HTML is Python-rendered on every lyric / state change so the markup
|
| 1472 |
+
# is always in sync. JS only handles the click-to-edit popover and writes the new
|
| 1473 |
+
# state back into the textbox below; Python then re-renders on that change.
|
| 1474 |
+
with gr.Accordion("✎ Stretch or re-spell individual words (click any word to edit)",
|
| 1475 |
+
open=False, elem_id="overrides-accord"):
|
| 1476 |
+
token_preview = gr.HTML(_render_token_preview(DEFAULT_TEXT, ""),
|
| 1477 |
+
elem_id="token-preview")
|
| 1478 |
+
overrides_text = gr.Textbox(
|
| 1479 |
+
label="override state (the click-to-edit writes here; you can also type by hand)",
|
| 1480 |
+
placeholder='advanced: word=pronunciation:stretch e.g. river=ree-vuh:1.4',
|
| 1481 |
+
lines=1,
|
| 1482 |
+
elem_id="overrides-state",
|
| 1483 |
+
value="",
|
| 1484 |
+
)
|
| 1485 |
+
# Click-to-edit on .token chips. Gradio v6 strips inline <script> tags inside
|
| 1486 |
+
# gr.HTML, so we register the delegated listener via demo.load(js=...) below.
|
| 1487 |
+
|
| 1488 |
+
# the dial — brass knob with vermillion arc and a Fraunces numeral center.
|
| 1489 |
+
# Tick numbers sit in a semicircle above the knob (0 on the left, 4 on the right).
|
| 1490 |
+
# The knob's indicator is a pointer needle that lines up with the active tick.
|
| 1491 |
+
gr.HTML(
|
| 1492 |
+
"""
|
| 1493 |
+
<div id="dial-stack">
|
| 1494 |
+
<div class="dial-tag">the dial</div>
|
| 1495 |
+
<div class="knob-stage">
|
| 1496 |
+
<div class="knob-ticks" id="knob-ticks">
|
| 1497 |
+
<span data-lv="0">0</span>
|
| 1498 |
+
<span data-lv="1">1</span>
|
| 1499 |
+
<span data-lv="2">2</span>
|
| 1500 |
+
<span data-lv="3">3</span>
|
| 1501 |
+
<span data-lv="4">4</span>
|
| 1502 |
+
</div>
|
| 1503 |
+
<div class="knob-wrap">
|
| 1504 |
+
<div class="knob" id="knob" tabindex="0" role="slider"
|
| 1505 |
+
aria-valuemin="0" aria-valuemax="4" aria-valuenow="0">
|
| 1506 |
+
<div class="knob-arc"></div>
|
| 1507 |
+
<div class="knob-pointer"></div>
|
| 1508 |
+
<div class="knob-pin"></div>
|
| 1509 |
+
</div>
|
| 1510 |
+
</div>
|
| 1511 |
+
</div>
|
| 1512 |
+
</div>
|
| 1513 |
+
"""
|
| 1514 |
+
)
|
| 1515 |
+
level = gr.Slider(0, 4, value=0, step=1, label="", elem_id="dial-slider",
|
| 1516 |
+
show_label=False, visible=True)
|
| 1517 |
+
|
| 1518 |
+
# Live preview of what the audio will say, updates as the dial / mode / lyric change.
|
| 1519 |
+
ghost_lyric = gr.Textbox(label="what the voice will say at this dial position",
|
| 1520 |
+
interactive=False, lines=2,
|
| 1521 |
+
value="",
|
| 1522 |
+
elem_classes="ghost-lyric",
|
| 1523 |
+
elem_id="ghost-lyric")
|
| 1524 |
+
|
| 1525 |
+
# post-fx as a quiet adjuster: how much room / reverb to wrap the voice in.
|
| 1526 |
+
postfx = gr.Dropdown(list(POSTFX_PRESETS.keys()), value="subtle",
|
| 1527 |
+
label="space (dry = bare voice, cathedral = drenched in reverb)")
|
| 1528 |
+
# seed is wired but hidden from the UI (it powers determinism but most users don't care).
|
| 1529 |
+
seed = gr.Number(value=42, precision=0, label="seed", visible=False)
|
| 1530 |
+
|
| 1531 |
+
# two actions: hear ONE dial position, or hear the WHOLE 0->4 dissolution in one take.
|
| 1532 |
+
# morph works in whichever mode is selected (Tongues or Ghost), so the label is neutral.
|
| 1533 |
+
with gr.Row(elem_classes="action-row"):
|
| 1534 |
+
speak_btn = gr.Button("play this dial", variant="primary",
|
| 1535 |
+
elem_classes="primary", scale=1)
|
| 1536 |
+
morph_btn = gr.Button("dissolve · sweep 0 → 4", variant="primary",
|
| 1537 |
+
elem_classes="primary", scale=1)
|
| 1538 |
+
|
| 1539 |
+
audio_out = gr.Audio(label="the take", type="filepath", autoplay=True,
|
| 1540 |
+
elem_id="audio-out")
|
| 1541 |
+
# Hidden — stub metrics block, only shown after we wire real Whisper-WER + Resemblyzer.
|
| 1542 |
+
metrics = gr.HTML(readout(), visible=False)
|
| 1543 |
+
|
| 1544 |
+
# Bind the brass knob to the hidden slider. Gradio strips inline <script> tags,
|
| 1545 |
+
# Inject the fixed background sun layer as a real <div> on <body>. A real node
|
| 1546 |
+
# (not a ::before pseudo) is immune to Gradio re-rendering its subtree and never
|
| 1547 |
+
# fights another element's pseudo slot. Idempotent so a reconnect can't duplicate it.
|
| 1548 |
+
demo.load(
|
| 1549 |
+
fn=None,
|
| 1550 |
+
js="""() => {
|
| 1551 |
+
if (!document.getElementById('bg-sun')) {
|
| 1552 |
+
const d = document.createElement('div');
|
| 1553 |
+
d.id = 'bg-sun';
|
| 1554 |
+
document.body.prepend(d);
|
| 1555 |
+
}
|
| 1556 |
+
return [];
|
| 1557 |
+
}""",
|
| 1558 |
+
)
|
| 1559 |
+
|
| 1560 |
+
# so we inject this via demo.load(js=...) which runs once on page mount.
|
| 1561 |
+
demo.load(
|
| 1562 |
+
fn=None,
|
| 1563 |
+
js="""() => {
|
| 1564 |
+
const LEVELS = 5;
|
| 1565 |
+
// 0 on the LEFT, 4 on the RIGHT, sweep over the TOP.
|
| 1566 |
+
const ANGLE_MIN = -90, ANGLE_MAX = 90;
|
| 1567 |
+
function getInp() { return document.querySelector('#dial-slider input[type="range"]'); }
|
| 1568 |
+
function levelToAngle(level) {
|
| 1569 |
+
const frac = level / (LEVELS - 1);
|
| 1570 |
+
return ANGLE_MIN + (ANGLE_MAX - ANGLE_MIN) * frac;
|
| 1571 |
+
}
|
| 1572 |
+
function positionTicks() {
|
| 1573 |
+
const ticks = document.querySelectorAll('.knob-ticks span');
|
| 1574 |
+
// .knob-ticks is positioned at the knob center, so polar coords directly:
|
| 1575 |
+
// tx = sin(angle)*R, ty = -cos(angle)*R. Knurled rim outer is at 88px, so
|
| 1576 |
+
// a radius of 102 sits the tick just outside the rim.
|
| 1577 |
+
const radius = 178;
|
| 1578 |
+
ticks.forEach(t => {
|
| 1579 |
+
const lv = Number(t.dataset.lv);
|
| 1580 |
+
const angle = levelToAngle(lv);
|
| 1581 |
+
const rad = angle * Math.PI / 180;
|
| 1582 |
+
const tx = Math.sin(rad) * radius;
|
| 1583 |
+
const ty = -Math.cos(rad) * radius;
|
| 1584 |
+
t.style.setProperty('--tx', tx + 'px');
|
| 1585 |
+
t.style.setProperty('--ty', ty + 'px');
|
| 1586 |
+
});
|
| 1587 |
+
}
|
| 1588 |
+
function render(level) {
|
| 1589 |
+
const knob = document.getElementById('knob'); if (!knob) return;
|
| 1590 |
+
const ticks = document.querySelectorAll('.knob-ticks span');
|
| 1591 |
+
const angle = levelToAngle(level);
|
| 1592 |
+
knob.style.setProperty('--knob-angle', angle + 'deg');
|
| 1593 |
+
knob.style.setProperty('--arc-deg', (angle - ANGLE_MIN) + 'deg');
|
| 1594 |
+
knob.setAttribute('aria-valuenow', level);
|
| 1595 |
+
ticks.forEach(t => t.classList.toggle('active', Number(t.dataset.lv) === level));
|
| 1596 |
+
}
|
| 1597 |
+
function setLevel(level, fire) {
|
| 1598 |
+
level = Math.max(0, Math.min(LEVELS - 1, Math.round(level)));
|
| 1599 |
+
render(level);
|
| 1600 |
+
if (fire) {
|
| 1601 |
+
const inp = getInp();
|
| 1602 |
+
if (inp) {
|
| 1603 |
+
inp.value = String(level);
|
| 1604 |
+
inp.dispatchEvent(new Event('input', {bubbles: true}));
|
| 1605 |
+
inp.dispatchEvent(new Event('change', {bubbles: true}));
|
| 1606 |
+
}
|
| 1607 |
+
}
|
| 1608 |
+
}
|
| 1609 |
+
function bind() {
|
| 1610 |
+
const knob = document.getElementById('knob');
|
| 1611 |
+
const inp = getInp();
|
| 1612 |
+
if (!knob || !inp || knob.dataset.bound === '1') return !!knob && !!inp;
|
| 1613 |
+
knob.dataset.bound = '1';
|
| 1614 |
+
positionTicks();
|
| 1615 |
+
render(parseInt(inp.value || '0', 10));
|
| 1616 |
+
inp.addEventListener('input', () => render(parseInt(inp.value || '0', 10)));
|
| 1617 |
+
// Rotational drag: convert pointer position to angle from knob center,
|
| 1618 |
+
// map that angle into 0..(LEVELS-1).
|
| 1619 |
+
let dragging = false;
|
| 1620 |
+
function angleFromEvent(e) {
|
| 1621 |
+
const r = knob.getBoundingClientRect();
|
| 1622 |
+
const cx = r.left + r.width / 2;
|
| 1623 |
+
const cy = r.top + r.height / 2;
|
| 1624 |
+
// 0 deg = pointing up; clockwise positive (standard CSS rotation).
|
| 1625 |
+
let a = Math.atan2(e.clientX - cx, cy - e.clientY) * 180 / Math.PI;
|
| 1626 |
+
return a; // range -180..+180
|
| 1627 |
+
}
|
| 1628 |
+
function angleToLevel(a) {
|
| 1629 |
+
// ANGLE_MIN = -135 -> level 0, ANGLE_MAX = +135 -> level 4
|
| 1630 |
+
let clamped = Math.max(ANGLE_MIN, Math.min(ANGLE_MAX, a));
|
| 1631 |
+
const frac = (clamped - ANGLE_MIN) / (ANGLE_MAX - ANGLE_MIN);
|
| 1632 |
+
return Math.round(frac * (LEVELS - 1));
|
| 1633 |
+
}
|
| 1634 |
+
knob.addEventListener('pointerdown', e => {
|
| 1635 |
+
dragging = true;
|
| 1636 |
+
knob.setPointerCapture(e.pointerId);
|
| 1637 |
+
knob.classList.add('dragging');
|
| 1638 |
+
setLevel(angleToLevel(angleFromEvent(e)), true);
|
| 1639 |
+
});
|
| 1640 |
+
knob.addEventListener('pointermove', e => {
|
| 1641 |
+
if (!dragging) return;
|
| 1642 |
+
setLevel(angleToLevel(angleFromEvent(e)), true);
|
| 1643 |
+
});
|
| 1644 |
+
const end = e => {
|
| 1645 |
+
if (!dragging) return;
|
| 1646 |
+
dragging = false;
|
| 1647 |
+
try { knob.releasePointerCapture(e.pointerId); } catch(_) {}
|
| 1648 |
+
knob.classList.remove('dragging');
|
| 1649 |
+
};
|
| 1650 |
+
knob.addEventListener('pointerup', end);
|
| 1651 |
+
knob.addEventListener('pointercancel', end);
|
| 1652 |
+
knob.addEventListener('wheel', e => {
|
| 1653 |
+
e.preventDefault();
|
| 1654 |
+
setLevel(parseInt(inp.value || '0', 10) + (e.deltaY < 0 ? 1 : -1), true);
|
| 1655 |
+
}, {passive: false});
|
| 1656 |
+
knob.addEventListener('keydown', e => {
|
| 1657 |
+
const cur = parseInt(inp.value || '0', 10);
|
| 1658 |
+
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') { e.preventDefault(); setLevel(cur + 1, true); }
|
| 1659 |
+
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') { e.preventDefault(); setLevel(cur - 1, true); }
|
| 1660 |
+
});
|
| 1661 |
+
document.querySelectorAll('.knob-ticks span').forEach(t => {
|
| 1662 |
+
t.addEventListener('click', () => setLevel(Number(t.dataset.lv), true));
|
| 1663 |
+
});
|
| 1664 |
+
return true;
|
| 1665 |
+
}
|
| 1666 |
+
// Gradio mounts components asynchronously; retry until both are present.
|
| 1667 |
+
let n = 0;
|
| 1668 |
+
const iv = setInterval(() => { if (bind() || ++n > 60) clearInterval(iv); }, 150);
|
| 1669 |
+
}""",
|
| 1670 |
+
)
|
| 1671 |
+
|
| 1672 |
+
# Click-to-edit on .token chips. Same demo.load(js=...) pattern as the knob —
|
| 1673 |
+
# inline <script> in gr.HTML is stripped by Gradio v6.
|
| 1674 |
+
demo.load(
|
| 1675 |
+
fn=None,
|
| 1676 |
+
js=r"""() => {
|
| 1677 |
+
function getStateTA() {
|
| 1678 |
+
const wrap = document.getElementById('overrides-state');
|
| 1679 |
+
return wrap ? wrap.querySelector('textarea, input') : null;
|
| 1680 |
+
}
|
| 1681 |
+
function parseState(s) {
|
| 1682 |
+
const out = {};
|
| 1683 |
+
(s || '').trim().split(/\s+/).forEach(t => {
|
| 1684 |
+
if (!t.includes('=')) return;
|
| 1685 |
+
const [w, rest] = t.split('=', 2);
|
| 1686 |
+
let repl = rest, stretch = '1.0';
|
| 1687 |
+
if (rest && rest.includes(':')) { [repl, stretch] = rest.split(':', 2); }
|
| 1688 |
+
out[w.toLowerCase()] = { repl: repl || w, stretch: parseFloat(stretch) || 1.0 };
|
| 1689 |
+
});
|
| 1690 |
+
return out;
|
| 1691 |
+
}
|
| 1692 |
+
function serializeState(state) {
|
| 1693 |
+
return Object.entries(state)
|
| 1694 |
+
.map(([w, v]) => `${w}=${v.repl}:${v.stretch.toFixed(2)}`)
|
| 1695 |
+
.join(' ');
|
| 1696 |
+
}
|
| 1697 |
+
function openEditor(anchor) {
|
| 1698 |
+
const stateTA = getStateTA();
|
| 1699 |
+
if (!stateTA) return;
|
| 1700 |
+
const word = anchor.dataset.word;
|
| 1701 |
+
const state = parseState(stateTA.value || '');
|
| 1702 |
+
const cur = state[word] || {
|
| 1703 |
+
repl: anchor.dataset.pron || word,
|
| 1704 |
+
stretch: parseFloat(anchor.dataset.stretch || '1.0'),
|
| 1705 |
+
};
|
| 1706 |
+
const old = document.querySelector('.token-editor');
|
| 1707 |
+
if (old) old.remove();
|
| 1708 |
+
const ed = document.createElement('div');
|
| 1709 |
+
ed.className = 'token-editor';
|
| 1710 |
+
ed.innerHTML = `
|
| 1711 |
+
<div class="te-row te-head">editing <b>${word}</b></div>
|
| 1712 |
+
<label class="te-row">pronunciation <input class="te-pron" type="text" value="${cur.repl}"></label>
|
| 1713 |
+
<label class="te-row">stretch <span class="te-val">${cur.stretch.toFixed(2)}x</span>
|
| 1714 |
+
<input class="te-stretch" type="range" min="0.5" max="2.5" step="0.05" value="${cur.stretch}"></label>
|
| 1715 |
+
<div class="te-row te-buttons">
|
| 1716 |
+
<button type="button" class="te-clear">clear</button>
|
| 1717 |
+
<button type="button" class="te-apply">apply</button>
|
| 1718 |
+
</div>`;
|
| 1719 |
+
anchor.insertAdjacentElement('afterend', ed);
|
| 1720 |
+
const pron = ed.querySelector('.te-pron');
|
| 1721 |
+
const sl = ed.querySelector('.te-stretch');
|
| 1722 |
+
const val = ed.querySelector('.te-val');
|
| 1723 |
+
sl.oninput = () => { val.textContent = parseFloat(sl.value).toFixed(2) + 'x'; };
|
| 1724 |
+
ed.querySelector('.te-apply').onclick = () => {
|
| 1725 |
+
const ns = parseState(stateTA.value || '');
|
| 1726 |
+
ns[word] = { repl: pron.value || word, stretch: parseFloat(sl.value) };
|
| 1727 |
+
stateTA.value = serializeState(ns);
|
| 1728 |
+
stateTA.dispatchEvent(new Event('input', { bubbles: true }));
|
| 1729 |
+
ed.remove();
|
| 1730 |
+
};
|
| 1731 |
+
ed.querySelector('.te-clear').onclick = () => {
|
| 1732 |
+
const ns = parseState(stateTA.value || '');
|
| 1733 |
+
delete ns[word];
|
| 1734 |
+
stateTA.value = serializeState(ns);
|
| 1735 |
+
stateTA.dispatchEvent(new Event('input', { bubbles: true }));
|
| 1736 |
+
ed.remove();
|
| 1737 |
+
};
|
| 1738 |
+
}
|
| 1739 |
+
// Delegate clicks: works even after Python re-renders the token preview.
|
| 1740 |
+
document.addEventListener('click', e => {
|
| 1741 |
+
const t = e.target.closest('#token-preview .token');
|
| 1742 |
+
if (t) openEditor(t);
|
| 1743 |
+
}, true);
|
| 1744 |
+
}""",
|
| 1745 |
+
)
|
| 1746 |
+
|
| 1747 |
+
level.change(lambda lv: readout(level=_safe_int(lv)),
|
| 1748 |
+
inputs=level, outputs=metrics)
|
| 1749 |
+
sentence.change(_render_token_preview,
|
| 1750 |
+
inputs=[sentence, overrides_text], outputs=token_preview)
|
| 1751 |
+
overrides_text.change(_render_token_preview,
|
| 1752 |
+
inputs=[sentence, overrides_text], outputs=token_preview)
|
| 1753 |
+
|
| 1754 |
+
# Live preview of "what the audio will say" as the dial / mode / lyric changes.
|
| 1755 |
+
# Ghost mode: mondegreen-substituted real English words.
|
| 1756 |
+
# Tongues mode: phoneme-corrupted pseudo-text.
|
| 1757 |
+
def _live_preview(text, lv, m, sd):
|
| 1758 |
+
try:
|
| 1759 |
+
lv = _safe_int(lv)
|
| 1760 |
+
sd = _safe_int(sd) or 42
|
| 1761 |
+
if not text or not text.strip():
|
| 1762 |
+
return ""
|
| 1763 |
+
# Dial=0 always returns the original lyric in either mode.
|
| 1764 |
+
if lv <= 0:
|
| 1765 |
+
return text.strip()
|
| 1766 |
+
if m == MODE_GHOST:
|
| 1767 |
+
# Ghost: real English words substituted via mondegreen + DistilGPT-2 rerank.
|
| 1768 |
+
return ENGINE.ghost_text(text, lv, seed=sd)
|
| 1769 |
+
else:
|
| 1770 |
+
# Tongues: pseudo-ASCII rendering of the phoneme corruption that the TTS
|
| 1771 |
+
# actually reads. Trimmed of inter-phoneme hyphens for readability.
|
| 1772 |
+
from scripts.corrupt_phonemes import load_lm, corrupt_sentence
|
| 1773 |
+
from pathlib import Path
|
| 1774 |
+
_lm = getattr(_live_preview, "_lm", None)
|
| 1775 |
+
if _lm is None:
|
| 1776 |
+
_lm = load_lm(Path("data/phoneme_lm.npz"))
|
| 1777 |
+
_live_preview._lm = _lm
|
| 1778 |
+
_, _, pseudo, _ = corrupt_sentence(text, lv, _lm, seed=sd)
|
| 1779 |
+
return pseudo
|
| 1780 |
+
except Exception as e:
|
| 1781 |
+
return f"(preview unavailable: {e})"
|
| 1782 |
+
|
| 1783 |
+
for trig in (level, sentence, mode, seed):
|
| 1784 |
+
trig.change(_live_preview,
|
| 1785 |
+
inputs=[sentence, level, mode, seed],
|
| 1786 |
+
outputs=ghost_lyric)
|
| 1787 |
+
# Also compute the preview once on initial page load so the textbox isn't empty.
|
| 1788 |
+
demo.load(_live_preview,
|
| 1789 |
+
inputs=[sentence, level, mode, seed],
|
| 1790 |
+
outputs=ghost_lyric)
|
| 1791 |
+
speak_btn.click(
|
| 1792 |
+
speak,
|
| 1793 |
+
inputs=[sentence, voice, level, postfx, mode, seed,
|
| 1794 |
+
custom_voice, custom_voice_text,
|
| 1795 |
+
music_input, music_gain, overrides_text],
|
| 1796 |
+
outputs=[audio_out, metrics, ghost_lyric],
|
| 1797 |
+
)
|
| 1798 |
+
morph_btn.click(
|
| 1799 |
+
morph,
|
| 1800 |
+
inputs=[sentence, voice, postfx, mode, seed,
|
| 1801 |
+
custom_voice, custom_voice_text,
|
| 1802 |
+
music_input, music_gain, overrides_text],
|
| 1803 |
+
outputs=[audio_out, metrics, ghost_lyric],
|
| 1804 |
+
)
|
| 1805 |
+
|
| 1806 |
+
gr.HTML(
|
| 1807 |
+
"""
|
| 1808 |
+
<div id="footer">
|
| 1809 |
+
<div>Open weights · all inference local · the dial is a fine-tuned
|
| 1810 |
+
control surface, not a DSP effect.</div>
|
| 1811 |
+
<div>Build-Small · Thousand Token Wood</div>
|
| 1812 |
+
</div>
|
| 1813 |
+
"""
|
| 1814 |
+
)
|
| 1815 |
+
|
| 1816 |
+
|
| 1817 |
+
if __name__ == "__main__":
|
| 1818 |
+
demo.launch(theme=_THEME, css=CUSTOM_CSS)
|