mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-10 17:10:18 -10:00
feat(pet): play the shared score in terminal Watch
Default-off Watch sound streams canonical stereo PCM to one optional ffplay child. Hide, quiet, stale presentation and mute cancel output without stopping the world. Bound and discard delayed or excess packets; isolate player/PCM failures from telemetry and persistence. Translate all controls. Validation on this tree: - npm test: 66 package + 12 SDK + 446 web passed, 0 failed. - npm run check:web: passed (2 existing lint warnings, 0 errors). - Isolated exact audio/PCM source: 5 passed, 0 failed. - Actual FFplay output: three 4-second busy-host runs passed after queue fix. - 4 strings translated in all 15 complete locale packs. - Full Watch suite: 11 passed, 0 failed before final queue-full recovery fix; final full Rust rerun/build and PTY acceptance are still in progress. No world, score, replay format or generated native bundle changes.
This commit is contained in:
1 parent
e69e99b7f5
commit
6dbec2c9b7
23 files changed
+771
-13
No files matched your search
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "So activat",
|
||||
"PetWatchSoundOff": "So desactivat",
|
||||
"PetWatchSoundPaused": "So en pausa",
|
||||
"PetWatchSoundUnavailable": "El so de Codewhale s’ha aturat. Comprova l’FFmpeg (ffplay) i la sortida d’àudio i torna a activar el so.",
|
||||
"PetWatchRestored": "Watch ha restaurat l’hàbitat d’aquesta sessió. S’espera telemetria nova.",
|
||||
"PetWatchStorageUnavailable": "Watch no ha pogut restaurar ni desar aquest hàbitat. S’han conservat els fitxers existents. Fes servir {command} per exportar aquesta execució.",
|
||||
"PetWatchExported": "Reproducció de Watch desada: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Ton an",
|
||||
"PetWatchSoundOff": "Ton aus",
|
||||
"PetWatchSoundPaused": "Ton pausiert",
|
||||
"PetWatchSoundUnavailable": "Die Tonausgabe wurde gestoppt. Prüfe FFmpeg (ffplay) und deinen Audioausgang und schalte den Ton erneut ein.",
|
||||
"PetWatchRestored": "Watch hat den Lebensraum dieser Sitzung wiederhergestellt. Warte auf neue Telemetrie.",
|
||||
"PetWatchStorageUnavailable": "Watch konnte diesen Lebensraum nicht wiederherstellen oder speichern. Vorhandene Dateien wurden beibehalten. Mit {command} kannst du diesen Lauf exportieren.",
|
||||
"PetWatchExported": "Watch-Aufzeichnung gespeichert: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Sound on",
|
||||
"PetWatchSoundOff": "Sound off",
|
||||
"PetWatchSoundPaused": "Sound paused",
|
||||
"PetWatchSoundUnavailable": "Pet sound stopped. Check FFmpeg (ffplay) and your audio output, then enable sound again.",
|
||||
"PetWatchRestored": "Watch restored this session’s habitat. Waiting for fresh telemetry.",
|
||||
"PetWatchStorageUnavailable": "Watch could not restore or save this habitat. Existing files were kept. Use {command} to export this run.",
|
||||
"PetWatchExported": "Watch replay saved: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Sonido activado",
|
||||
"PetWatchSoundOff": "Sonido desactivado",
|
||||
"PetWatchSoundPaused": "Sonido en pausa",
|
||||
"PetWatchSoundUnavailable": "El sonido de Codewhale se detuvo. Revisa FFmpeg (ffplay) y la salida de audio, y vuelve a activar el sonido.",
|
||||
"PetWatchRestored": "Watch restauró el hábitat de esta sesión. Esperando telemetría nueva.",
|
||||
"PetWatchStorageUnavailable": "Watch no pudo restaurar ni guardar este hábitat. Se conservaron los archivos existentes. Usa {command} para exportar esta ejecución.",
|
||||
"PetWatchExported": "Reproducción de Watch guardada: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Son activé",
|
||||
"PetWatchSoundOff": "Son désactivé",
|
||||
"PetWatchSoundPaused": "Son en pause",
|
||||
"PetWatchSoundUnavailable": "Le son de Codewhale s’est arrêté. Vérifiez FFmpeg (ffplay) et la sortie audio, puis réactivez le son.",
|
||||
"PetWatchRestored": "Watch a restauré l’habitat de cette session. En attente de nouvelles données de télémétrie.",
|
||||
"PetWatchStorageUnavailable": "Watch n’a pas pu restaurer ou enregistrer cet habitat. Les fichiers existants ont été conservés. Utilisez {command} pour exporter cette exécution.",
|
||||
"PetWatchExported": "Enregistrement Watch sauvegardé : {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "ध्वनि चालू",
|
||||
"PetWatchSoundOff": "ध्वनि बंद",
|
||||
"PetWatchSoundPaused": "ध्वनि रुकी हुई",
|
||||
"PetWatchSoundUnavailable": "Codewhale की ध्वनि रुक गई। FFmpeg (ffplay) और ऑडियो आउटपुट की जाँच करें, फिर ध्वनि फिर से चालू करें।",
|
||||
"PetWatchRestored": "Watch ने इस सत्र का आवास बहाल कर दिया है। नई टेलीमेट्री की प्रतीक्षा है।",
|
||||
"PetWatchStorageUnavailable": "Watch इस आवास को बहाल या सहेज नहीं सका। मौजूदा फ़ाइलें सुरक्षित हैं। इस रन को निर्यात करने के लिए {command} इस्तेमाल करें।",
|
||||
"PetWatchExported": "Watch रिकॉर्डिंग सहेजी गई: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Suara aktif",
|
||||
"PetWatchSoundOff": "Suara nonaktif",
|
||||
"PetWatchSoundPaused": "Suara dijeda",
|
||||
"PetWatchSoundUnavailable": "Suara Codewhale berhenti. Periksa FFmpeg (ffplay) dan keluaran audio, lalu aktifkan suara kembali.",
|
||||
"PetWatchRestored": "Watch memulihkan habitat sesi ini. Menunggu telemetri baru.",
|
||||
"PetWatchStorageUnavailable": "Watch tidak dapat memulihkan atau menyimpan habitat ini. Berkas yang ada tetap disimpan. Gunakan {command} untuk mengekspor proses ini.",
|
||||
"PetWatchExported": "Rekaman Watch disimpan: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "サウンド有効",
|
||||
"PetWatchSoundOff": "サウンド無効",
|
||||
"PetWatchSoundPaused": "サウンド一時停止",
|
||||
"PetWatchSoundUnavailable": "Codewhaleのサウンドが停止しました。FFmpeg (ffplay) とオーディオ出力を確認し、サウンドを再度有効にしてください。",
|
||||
"PetWatchRestored": "Watch がこのセッションの生息環境を復元しました。新しいテレメトリを待っています。",
|
||||
"PetWatchStorageUnavailable": "Watch はこの生息環境を復元または保存できませんでした。既存のファイルは保持されています。{command} で今回の記録をエクスポートできます。",
|
||||
"PetWatchExported": "Watch の記録を保存しました: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "소리 켜짐",
|
||||
"PetWatchSoundOff": "소리 꺼짐",
|
||||
"PetWatchSoundPaused": "소리 일시 중지",
|
||||
"PetWatchSoundUnavailable": "Codewhale 소리가 멈췄습니다. FFmpeg (ffplay)와 오디오 출력을 확인한 후 소리를 다시 켜세요.",
|
||||
"PetWatchRestored": "Watch가 이 세션의 서식지를 복원했습니다. 새 원격 측정 데이터를 기다립니다.",
|
||||
"PetWatchStorageUnavailable": "Watch가 이 서식지를 복원하거나 저장하지 못했습니다. 기존 파일은 유지되었습니다. {command}로 이번 실행을 내보내세요.",
|
||||
"PetWatchExported": "Watch 기록 저장됨: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Som ligado",
|
||||
"PetWatchSoundOff": "Som desligado",
|
||||
"PetWatchSoundPaused": "Som pausado",
|
||||
"PetWatchSoundUnavailable": "O som do Codewhale parou. Verifique o FFmpeg (ffplay) e a saída de áudio e ligue o som novamente.",
|
||||
"PetWatchRestored": "O Watch restaurou o habitat desta sessão. Aguardando nova telemetria.",
|
||||
"PetWatchStorageUnavailable": "O Watch não conseguiu restaurar ou salvar este habitat. Os arquivos existentes foram mantidos. Use {command} para exportar esta execução.",
|
||||
"PetWatchExported": "Gravação do Watch salva: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Звук включён",
|
||||
"PetWatchSoundOff": "Звук выключен",
|
||||
"PetWatchSoundPaused": "Звук приостановлен",
|
||||
"PetWatchSoundUnavailable": "Звук Codewhale остановлен. Проверьте FFmpeg (ffplay) и аудиовыход, затем снова включите звук.",
|
||||
"PetWatchRestored": "Watch восстановил среду этой сессии. Ожидание новой телеметрии.",
|
||||
"PetWatchStorageUnavailable": "Watch не удалось восстановить или сохранить эту среду. Существующие файлы сохранены. Используйте {command}, чтобы экспортировать этот запуск.",
|
||||
"PetWatchExported": "Запись Watch сохранена: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Звук увімкнено",
|
||||
"PetWatchSoundOff": "Звук вимкнено",
|
||||
"PetWatchSoundPaused": "Звук призупинено",
|
||||
"PetWatchSoundUnavailable": "Звук Codewhale зупинено. Перевірте FFmpeg (ffplay) та аудіовихід, а потім знову ввімкніть звук.",
|
||||
"PetWatchRestored": "Watch відновив середовище цієї сесії. Очікування нової телеметрії.",
|
||||
"PetWatchStorageUnavailable": "Watch не вдалося відновити або зберегти це середовище. Наявні файли збережено. Скористайтеся {command}, щоб експортувати цей запуск.",
|
||||
"PetWatchExported": "Запис Watch збережено: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "Âm thanh bật",
|
||||
"PetWatchSoundOff": "Âm thanh tắt",
|
||||
"PetWatchSoundPaused": "Âm thanh tạm dừng",
|
||||
"PetWatchSoundUnavailable": "Âm thanh Codewhale đã dừng. Hãy kiểm tra FFmpeg (ffplay) và đầu ra âm thanh, rồi bật lại âm thanh.",
|
||||
"PetWatchRestored": "Watch đã khôi phục môi trường của phiên này. Đang chờ dữ liệu đo mới.",
|
||||
"PetWatchStorageUnavailable": "Watch không thể khôi phục hoặc lưu môi trường này. Các tệp hiện có được giữ nguyên. Dùng {command} để xuất lần chạy này.",
|
||||
"PetWatchExported": "Đã lưu bản ghi Watch: {path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "声音已开启",
|
||||
"PetWatchSoundOff": "声音已关闭",
|
||||
"PetWatchSoundPaused": "声音已暂停",
|
||||
"PetWatchSoundUnavailable": "Codewhale 声音已停止。请检查 FFmpeg (ffplay) 和音频输出,然后重新开启声音。",
|
||||
"PetWatchRestored": "Watch 已恢复此会话的栖息地,正在等待新的遥测数据。",
|
||||
"PetWatchStorageUnavailable": "Watch 无法恢复或保存此栖息地。现有文件已保留。使用 {command} 导出本次运行。",
|
||||
"PetWatchExported": "Watch 回放已保存:{path}",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"PetWatchSoundOn": "聲音已開啟",
|
||||
"PetWatchSoundOff": "聲音已關閉",
|
||||
"PetWatchSoundPaused": "聲音已暫停",
|
||||
"PetWatchSoundUnavailable": "Codewhale 聲音已停止。請檢查 FFmpeg (ffplay) 和音訊輸出,然後重新開啟聲音。",
|
||||
"PetWatchRestored": "Watch 已還原此工作階段的棲息地,正在等待新的遙測資料。",
|
||||
"PetWatchStorageUnavailable": "Watch 無法還原或儲存此棲息地。現有檔案已保留。使用 {command} 匯出本次執行。",
|
||||
"PetWatchExported": "Watch 重播已儲存:{path}",
|
||||
|
||||
@@ -782,6 +782,10 @@ pub enum MessageId {
|
||||
PetWatchExportFailed,
|
||||
PetWatchExportQueued,
|
||||
PetWatchExportUnavailable,
|
||||
PetWatchSoundOn,
|
||||
PetWatchSoundOff,
|
||||
PetWatchSoundPaused,
|
||||
PetWatchSoundUnavailable,
|
||||
CmdLaneDescription,
|
||||
CmdWorkflowDescription,
|
||||
CmdWorkflowsDescription,
|
||||
@@ -3100,6 +3104,10 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::PetWatchExportFailed,
|
||||
MessageId::PetWatchExportQueued,
|
||||
MessageId::PetWatchExportUnavailable,
|
||||
MessageId::PetWatchSoundOn,
|
||||
MessageId::PetWatchSoundOff,
|
||||
MessageId::PetWatchSoundPaused,
|
||||
MessageId::PetWatchSoundUnavailable,
|
||||
MessageId::CmdLaneDescription,
|
||||
MessageId::CmdWorkflowDescription,
|
||||
MessageId::CmdWorkflowsDescription,
|
||||
|
||||
@@ -615,6 +615,40 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
const USAGE: &str =
|
||||
"Usage: /workbar [bottom|top|left|right|off|tasks|agents|context|watch|pinned] [--save]";
|
||||
let raw = arg.map(str::trim).unwrap_or("");
|
||||
let sound_args = raw
|
||||
.split_whitespace()
|
||||
.map(str::to_ascii_lowercase)
|
||||
.collect::<Vec<_>>();
|
||||
if let [watch, sound, rest @ ..] = sound_args.as_slice()
|
||||
&& watch == "watch"
|
||||
&& sound == "sound"
|
||||
{
|
||||
let enabled = match rest {
|
||||
[] => None,
|
||||
[value] if value == "on" => Some(true),
|
||||
[value] if value == "off" => Some(false),
|
||||
_ => return CommandResult::error("/workbar watch sound on|off"),
|
||||
};
|
||||
if let Some(enabled) = enabled {
|
||||
app.pet_watch.set_sound(enabled);
|
||||
if enabled {
|
||||
crate::tui::work_surface::select_dock_panel(
|
||||
app,
|
||||
crate::tui::work_surface::RailPanel::Watch,
|
||||
);
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
let label = if enabled == Some(true) {
|
||||
MessageId::PetWatchSoundOn
|
||||
} else {
|
||||
app.pet_watch.sound_label()
|
||||
};
|
||||
return CommandResult::message(format!(
|
||||
"{} · /workbar watch sound on|off",
|
||||
tr(app.ui_locale, label)
|
||||
));
|
||||
}
|
||||
if raw.eq_ignore_ascii_case("watch export") {
|
||||
return if app.pet_watch.export() {
|
||||
CommandResult::message(tr(app.ui_locale, MessageId::PetWatchExportQueued))
|
||||
@@ -3700,6 +3734,33 @@ mod tests {
|
||||
assert!(message.contains("bottom placement"), "got: {message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn watch_sound_command_is_opt_in_and_rejects_invalid_changes() {
|
||||
let mut app = create_test_app();
|
||||
let initial_panel = app.work_surface.panel;
|
||||
let status = sidebar(&mut app, Some("watch sound"));
|
||||
assert!(!status.is_error);
|
||||
assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff);
|
||||
assert_eq!(app.work_surface.panel, initial_panel);
|
||||
|
||||
assert!(!sidebar(&mut app, Some(" WATCH sound ON ")).is_error);
|
||||
assert_eq!(
|
||||
app.work_surface.panel,
|
||||
crate::tui::work_surface::RailPanel::Watch
|
||||
);
|
||||
assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused);
|
||||
for invalid in [
|
||||
"watch sound yes",
|
||||
"watch sound off extra",
|
||||
"watch sound on --save",
|
||||
] {
|
||||
assert!(sidebar(&mut app, Some(invalid)).is_error);
|
||||
assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused);
|
||||
}
|
||||
assert!(!sidebar(&mut app, Some("watch sound off")).is_error);
|
||||
assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rail_command_reports_narrow_terminal_top_fallback() {
|
||||
let mut app = create_test_app();
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
//! A bounded presentation sink for the shared core's stereo PCM. No score,
|
||||
//! event interpretation or simulation clock lives in the audio player.
|
||||
use std::io::{self, Write};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const SAMPLE_RATE: usize = 48_000;
|
||||
pub const MAX_FRAMES: usize = SAMPLE_RATE / 2;
|
||||
|
||||
pub(super) struct Packet {
|
||||
pub(super) bytes: Vec<u8>,
|
||||
created: Instant,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Control {
|
||||
child: Mutex<Option<Child>>,
|
||||
cancelled: AtomicBool,
|
||||
failed: AtomicBool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Target {
|
||||
tx: mpsc::SyncSender<Packet>,
|
||||
control: Arc<Control>,
|
||||
requested: Instant,
|
||||
}
|
||||
|
||||
impl Target {
|
||||
pub fn same_stream(&self, other: &Self) -> bool {
|
||||
Arc::ptr_eq(&self.control, &other.control)
|
||||
}
|
||||
|
||||
pub fn active(&self) -> bool {
|
||||
!self.control.cancelled.load(Ordering::Acquire)
|
||||
&& !self.control.failed.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn fail(&self) {
|
||||
self.control.failed.store(true, Ordering::Release);
|
||||
}
|
||||
|
||||
pub fn current(&self) -> bool {
|
||||
self.requested.elapsed() <= Duration::from_millis(500)
|
||||
}
|
||||
|
||||
pub fn send(&self, channels: [Vec<f32>; 2]) -> Result<(), ()> {
|
||||
let length = channels[0].len();
|
||||
if length > MAX_FRAMES
|
||||
|| channels[1].len() != length
|
||||
|| channels
|
||||
.iter()
|
||||
.flatten()
|
||||
.any(|s| !s.is_finite() || s.abs() > 1.0)
|
||||
{
|
||||
self.fail();
|
||||
return Err(());
|
||||
}
|
||||
if !self.active() {
|
||||
return Err(());
|
||||
}
|
||||
if !self.current() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut bytes = Vec::with_capacity(length * 8);
|
||||
for i in 0..length {
|
||||
bytes.extend_from_slice(&channels[0][i].to_le_bytes());
|
||||
bytes.extend_from_slice(&channels[1][i].to_le_bytes());
|
||||
}
|
||||
match self.tx.try_send(Packet {
|
||||
bytes,
|
||||
created: self.requested,
|
||||
}) {
|
||||
Ok(()) | Err(mpsc::TrySendError::Full(_)) => Ok(()),
|
||||
Err(mpsc::TrySendError::Disconnected(_)) => {
|
||||
self.fail();
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Output {
|
||||
target: Target,
|
||||
}
|
||||
|
||||
impl Output {
|
||||
#[cfg(test)]
|
||||
pub(super) fn capture() -> (Self, mpsc::Receiver<Packet>) {
|
||||
let (tx, rx) = mpsc::sync_channel(4);
|
||||
(
|
||||
Self {
|
||||
target: Target {
|
||||
tx,
|
||||
control: Arc::new(Control::default()),
|
||||
requested: Instant::now(),
|
||||
},
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
pub fn start() -> io::Result<Self> {
|
||||
let mut command = Command::new("ffplay");
|
||||
command.args([
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-probesize",
|
||||
"32",
|
||||
"-analyzeduration",
|
||||
"0",
|
||||
"-f",
|
||||
"f32le",
|
||||
"-sample_rate",
|
||||
"48000",
|
||||
"-ch_layout",
|
||||
"stereo",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
]);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW
|
||||
}
|
||||
Self::spawn(command)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn start() -> io::Result<Self> {
|
||||
// Product/library tests never open the user's audio output.
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"audio disabled in tests",
|
||||
))
|
||||
}
|
||||
|
||||
fn spawn(mut command: Command) -> io::Result<Self> {
|
||||
let (tx, rx) = mpsc::sync_channel(4);
|
||||
let control = Arc::new(Control::default());
|
||||
let thread_control = Arc::clone(&control);
|
||||
std::thread::Builder::new()
|
||||
.name("pet-audio".into())
|
||||
.spawn(move || {
|
||||
let result = play(&mut command, rx, &thread_control);
|
||||
if result.is_err() && !thread_control.cancelled.load(Ordering::Acquire) {
|
||||
thread_control.failed.store(true, Ordering::Release);
|
||||
}
|
||||
let child = thread_control
|
||||
.child
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut slot| slot.take());
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
})?;
|
||||
Ok(Self {
|
||||
target: Target {
|
||||
tx,
|
||||
control,
|
||||
requested: Instant::now(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn target(&self) -> Target {
|
||||
Target {
|
||||
requested: Instant::now(),
|
||||
..self.target.clone()
|
||||
}
|
||||
}
|
||||
pub fn failed(&self) -> bool {
|
||||
self.target.control.failed.load(Ordering::Acquire)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Output {
|
||||
fn drop(&mut self) {
|
||||
self.target.control.cancelled.store(true, Ordering::Release);
|
||||
// Closing or muting Watch interrupts even a blocked pipe write. Reaping
|
||||
// happens in the audio thread, never in the terminal event loop.
|
||||
if let Ok(mut slot) = self.target.control.child.lock()
|
||||
&& let Some(child) = slot.as_mut()
|
||||
{
|
||||
let _ = child.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn play(command: &mut Command, rx: mpsc::Receiver<Packet>, control: &Control) -> io::Result<()> {
|
||||
if control.cancelled.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut child = command
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
let Some(mut input) = child.stdin.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(io::Error::other("audio pipe unavailable"));
|
||||
};
|
||||
match control.child.lock() {
|
||||
Ok(mut slot) => *slot = Some(child),
|
||||
Err(_) => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(io::Error::other("audio lock failed"));
|
||||
}
|
||||
}
|
||||
while !control.cancelled.load(Ordering::Acquire) && !control.failed.load(Ordering::Acquire) {
|
||||
if control
|
||||
.child
|
||||
.lock()
|
||||
.map_err(|_| io::Error::other("audio lock failed"))?
|
||||
.as_mut()
|
||||
.is_none_or(|c| c.try_wait().map_or(true, |status| status.is_some()))
|
||||
{
|
||||
return Err(io::Error::other("audio player exited"));
|
||||
}
|
||||
match rx.recv_timeout(Duration::from_millis(50)) {
|
||||
Ok(packet) => {
|
||||
if packet.created.elapsed() > Duration::from_millis(500) {
|
||||
continue;
|
||||
}
|
||||
input.write_all(&packet.bytes)?;
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn wait_until(mut ready: impl FnMut() -> bool) {
|
||||
let until = Instant::now() + Duration::from_secs(5);
|
||||
while !ready() {
|
||||
assert!(Instant::now() < until, "audio process did not settle");
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
}
|
||||
}
|
||||
|
||||
fn receiver() -> (Target, mpsc::Receiver<Packet>) {
|
||||
let (tx, rx) = mpsc::sync_channel(4);
|
||||
(
|
||||
Target {
|
||||
tx,
|
||||
control: Arc::new(Control::default()),
|
||||
requested: Instant::now(),
|
||||
},
|
||||
rx,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pcm_boundary_preserves_samples_rejects_invalid_input_and_discards_backlog() {
|
||||
let (target, rx) = receiver();
|
||||
target.send([vec![0.125, -0.5], vec![0.25, 0.75]]).unwrap();
|
||||
let bytes: Vec<_> = [0.125_f32, 0.25, -0.5, 0.75]
|
||||
.into_iter()
|
||||
.flat_map(f32::to_le_bytes)
|
||||
.collect();
|
||||
assert_eq!(rx.recv().unwrap().bytes, bytes);
|
||||
for channels in [
|
||||
[vec![0.0], vec![]],
|
||||
[vec![f32::NAN], vec![0.0]],
|
||||
[vec![1.01], vec![0.0]],
|
||||
[vec![0.0; MAX_FRAMES + 1], vec![0.0; MAX_FRAMES + 1]],
|
||||
] {
|
||||
let (target, rx) = receiver();
|
||||
assert!(target.send(channels).is_err());
|
||||
assert!(!target.active());
|
||||
assert!(rx.try_recv().is_err());
|
||||
}
|
||||
let (mut target, rx) = receiver();
|
||||
target.requested = Instant::now() - Duration::from_secs(1);
|
||||
assert!(target.send([vec![0.0], vec![0.0]]).is_ok());
|
||||
assert!(target.active());
|
||||
assert!(rx.try_recv().is_err());
|
||||
let (target, rx) = receiver();
|
||||
for _ in 0..4 {
|
||||
target.send([vec![0.0], vec![0.0]]).unwrap();
|
||||
}
|
||||
assert!(target.send([vec![0.0], vec![0.0]]).is_ok());
|
||||
assert!(target.active());
|
||||
assert_eq!(rx.try_iter().count(), 4);
|
||||
}
|
||||
|
||||
/// Only a test subprocess with this explicit environment enters the sink.
|
||||
/// Ordinary library/nextest runs return without launching or playing audio.
|
||||
#[test]
|
||||
fn pet_audio_fake_player() {
|
||||
let Some(path) = std::env::var_os("CODEWHALE_TEST_PET_AUDIO_OUTPUT") else {
|
||||
return;
|
||||
};
|
||||
let mut file = std::fs::File::create(path).unwrap();
|
||||
if std::env::var_os("CODEWHALE_TEST_PET_AUDIO_HOLD").is_some() {
|
||||
std::thread::sleep(Duration::from_secs(30));
|
||||
} else {
|
||||
std::io::copy(&mut std::io::stdin().lock(), &mut file).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn fake_player(path: &std::path::Path, hold: bool) -> Command {
|
||||
let module = module_path!().split_once("::").unwrap().1;
|
||||
let mut command = Command::new(std::env::current_exe().unwrap());
|
||||
command
|
||||
.args([
|
||||
"--exact",
|
||||
&format!("{module}::pet_audio_fake_player"),
|
||||
"--nocapture",
|
||||
])
|
||||
.env("CODEWHALE_TEST_PET_AUDIO_OUTPUT", path);
|
||||
if hold {
|
||||
command.env("CODEWHALE_TEST_PET_AUDIO_HOLD", "1");
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn muting_reaps_the_player_even_with_a_retained_target_and_blocked_pipe() {
|
||||
for hold in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("received.f32");
|
||||
let output = Output::spawn(fake_player(&path, hold)).unwrap();
|
||||
wait_until(|| path.exists());
|
||||
let target = output.target();
|
||||
let control = Arc::clone(&target.control);
|
||||
target
|
||||
.send([vec![0.125; MAX_FRAMES], vec![-0.25; MAX_FRAMES]])
|
||||
.unwrap();
|
||||
if !hold {
|
||||
wait_until(|| std::fs::metadata(&path).unwrap().len() == (MAX_FRAMES * 8) as u64);
|
||||
}
|
||||
drop(output);
|
||||
assert!(!target.active());
|
||||
assert!(target.send([vec![0.0], vec![0.0]]).is_err());
|
||||
// Only this test and the deliberately retained target remain. The
|
||||
// player thread must have returned after killing and reaping it.
|
||||
wait_until(|| Arc::strong_count(&control) == 2);
|
||||
assert!(control.child.lock().unwrap().is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_player_fails_without_opening_a_device_or_blocking_the_caller() {
|
||||
assert!(Output::start().is_err());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let output = Output::spawn(Command::new(dir.path().join("missing-player"))).unwrap();
|
||||
wait_until(|| output.failed());
|
||||
assert!(!output.target().active());
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use codewhale_localization::{MessageId, tr};
|
||||
use codewhale_palette::{ChromeInk, chrome_style};
|
||||
use ratatui::{Frame, layout::Rect, style::Modifier};
|
||||
use serde_json::{Value, json};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::core::events::Event;
|
||||
use crate::core::protocol_parity::{ProtocolIds, event_to_protocol};
|
||||
@@ -15,6 +16,7 @@ use crate::tui::app::{App, StatusToastLevel};
|
||||
use crate::tui::underwater::ShellPhase;
|
||||
use crate::tui::work_surface::RailPanel;
|
||||
|
||||
mod audio;
|
||||
mod persistence;
|
||||
mod worker;
|
||||
use worker::{Command, Notice, Raster, Worker};
|
||||
@@ -27,11 +29,40 @@ pub struct PetWatch {
|
||||
last_tick: Option<Instant>,
|
||||
failed: bool,
|
||||
exporting: bool,
|
||||
sound_requested: bool,
|
||||
audio: Option<audio::Output>,
|
||||
pub(crate) area: Option<Rect>,
|
||||
raster: Option<Raster>,
|
||||
}
|
||||
|
||||
impl PetWatch {
|
||||
pub fn set_sound(&mut self, enabled: bool) {
|
||||
self.sound_requested = enabled;
|
||||
if !enabled {
|
||||
self.audio = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sound_label(&self) -> MessageId {
|
||||
if !self.sound_requested {
|
||||
MessageId::PetWatchSoundOff
|
||||
} else if self.audio.is_some() {
|
||||
MessageId::PetWatchSoundOn
|
||||
} else {
|
||||
MessageId::PetWatchSoundPaused
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self, session: Option<String>) {
|
||||
// The app-local user preference survives session selection; the old
|
||||
// stream and all of its queued sound are cancelled with this reset.
|
||||
*self = Self {
|
||||
session,
|
||||
sound_requested: self.sound_requested,
|
||||
..Self::default()
|
||||
};
|
||||
}
|
||||
|
||||
pub fn export(&mut self) -> bool {
|
||||
if self.worker.is_none() || self.session.is_none() || self.failed || self.exporting {
|
||||
return false;
|
||||
@@ -43,13 +74,13 @@ impl PetWatch {
|
||||
|
||||
pub fn retry(&mut self) {
|
||||
if self.failed {
|
||||
*self = Self::default();
|
||||
self.reset(self.session.clone());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn observe(&mut self, event: &Event, session: Option<&str>, now: Instant) {
|
||||
if self.session.as_deref() != session {
|
||||
*self = Self::default();
|
||||
self.reset(session.map(str::to_owned));
|
||||
return;
|
||||
}
|
||||
if self.worker.is_none() {
|
||||
@@ -72,6 +103,7 @@ impl PetWatch {
|
||||
self.failed = true;
|
||||
self.worker = None;
|
||||
self.raster = None;
|
||||
self.audio = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,10 +182,7 @@ pub fn tick(app: &mut App, now: Instant, obscured: bool) {
|
||||
let waiting = matches!(phase, ShellPhase::Waiting | ShellPhase::Approval);
|
||||
let state = &mut app.pet_watch;
|
||||
if state.session != app.current_session_id {
|
||||
*state = PetWatch {
|
||||
session: app.current_session_id.clone(),
|
||||
..PetWatch::default()
|
||||
};
|
||||
state.reset(app.current_session_id.clone());
|
||||
}
|
||||
if visible && state.worker.is_none() && !state.failed {
|
||||
state.origin = Some(now);
|
||||
@@ -201,6 +230,34 @@ pub fn tick(app: &mut App, now: Instant, obscured: bool) {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
let previous_sound = state.sound_label();
|
||||
let mut audio_failed = state.audio.as_ref().is_some_and(audio::Output::failed);
|
||||
let sound_allowed = visible
|
||||
&& state.worker.is_some()
|
||||
&& state.raster.is_some()
|
||||
&& !state.failed
|
||||
&& app.onboarding == crate::tui::app::OnboardingState::None
|
||||
&& !app.launch.visible
|
||||
&& app.view_stack.is_empty()
|
||||
&& !app.notification_settings.quiet
|
||||
&& !app.notification_settings.event_sound.quiet;
|
||||
if state.sound_requested && sound_allowed && !audio_failed {
|
||||
if state.audio.is_none() {
|
||||
match audio::Output::start() {
|
||||
Ok(output) => state.audio = Some(output),
|
||||
Err(_) => audio_failed = true,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.audio = None;
|
||||
}
|
||||
if audio_failed {
|
||||
state.sound_requested = false;
|
||||
state.audio = None;
|
||||
}
|
||||
if visible && previous_sound != state.sound_label() {
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
if state.worker.is_some()
|
||||
&& state.last_tick.is_none_or(|last| {
|
||||
now.duration_since(last) >= Duration::from_millis(if motion { 33 } else { 400 })
|
||||
@@ -216,6 +273,7 @@ pub fn tick(app: &mut App, now: Instant, obscured: bool) {
|
||||
waiting,
|
||||
width: area.width.clamp(1, 512),
|
||||
height: area.height.saturating_sub(1).clamp(1, 512),
|
||||
audio: state.audio.as_ref().map(audio::Output::target),
|
||||
});
|
||||
state.last_tick = Some(now);
|
||||
}
|
||||
@@ -227,6 +285,14 @@ pub fn tick(app: &mut App, now: Instant, obscured: bool) {
|
||||
);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
if audio_failed {
|
||||
app.push_status_toast(
|
||||
tr(app.ui_locale, MessageId::PetWatchSoundUnavailable).into_owned(),
|
||||
StatusToastLevel::Warning,
|
||||
Some(12_000),
|
||||
);
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
let notices: Vec<_> = app
|
||||
.pet_watch
|
||||
.worker
|
||||
@@ -281,7 +347,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
.and_then(|r| ChannelId::from_key(&r.channel))
|
||||
.unwrap_or(ChannelId::Other);
|
||||
let arch = raster.map_or("drift", |r| r.arch.as_str());
|
||||
let label = format!(
|
||||
let mut label = format!(
|
||||
"{channel} · {arch}{}",
|
||||
if hollow {
|
||||
format!(" · {}", tr(app.ui_locale, MessageId::PetUnobserved))
|
||||
@@ -291,6 +357,12 @@ pub fn render(frame: &mut Frame, area: Rect, app: &mut App) {
|
||||
String::new()
|
||||
}
|
||||
);
|
||||
let sound = tr(app.ui_locale, app.pet_watch.sound_label());
|
||||
// Keep the semantic/uncertainty cue before spending narrow cells on controls.
|
||||
if label.width() + sound.width() + 3 <= usize::from(area.width) {
|
||||
label.push_str(" · ");
|
||||
label.push_str(&sound);
|
||||
}
|
||||
let ink = if hollow {
|
||||
ChromeInk::Metadata
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::time::{Duration, Instant};
|
||||
use rquickjs::{Context, Runtime};
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::audio::{self, Target};
|
||||
use super::persistence::{self, Store};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Deserialize)]
|
||||
@@ -52,6 +53,7 @@ pub enum Command {
|
||||
waiting: bool,
|
||||
width: u16,
|
||||
height: u16,
|
||||
audio: Option<Target>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -68,6 +70,70 @@ pub struct Worker {
|
||||
pub notices: mpsc::Receiver<Notice>,
|
||||
}
|
||||
|
||||
/// Presentation retains only the voices that can still contribute samples.
|
||||
/// Their identities, timestamps and PCM all come from the existing JS core.
|
||||
struct AudioCursor {
|
||||
target: Target,
|
||||
sample: usize,
|
||||
voices: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl AudioCursor {
|
||||
fn present(
|
||||
&mut self,
|
||||
ctx: &rquickjs::Ctx<'_>,
|
||||
target: &Target,
|
||||
time_ms: f64,
|
||||
) -> rquickjs::Result<()> {
|
||||
let end = (time_ms * audio::SAMPLE_RATE as f64 / 1000.0).floor() as usize;
|
||||
if !target.current() {
|
||||
self.sample = end;
|
||||
self.voices.clear();
|
||||
return Ok(());
|
||||
}
|
||||
// A pause, new output or delayed catch-up cannot play historical sound.
|
||||
if end < self.sample || end - self.sample > audio::MAX_FRAMES {
|
||||
self.sample = end;
|
||||
self.voices.clear();
|
||||
}
|
||||
let json: String = ctx.eval("JSON.stringify(JSON.parse(pet.snapshot()).voices)")?;
|
||||
let voices: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&json).map_err(|_| rquickjs::Error::Unknown)?;
|
||||
let start = self.sample as f64 / audio::SAMPLE_RATE as f64;
|
||||
self.voices.extend(voices);
|
||||
self.voices.retain(|v| {
|
||||
v["start"]
|
||||
.as_f64()
|
||||
.zip(v["duration"].as_f64())
|
||||
.is_some_and(|(at, duration)| at + duration > start)
|
||||
});
|
||||
if self.voices.len() > 128 {
|
||||
return Err(rquickjs::Error::Unknown);
|
||||
}
|
||||
if end == self.sample {
|
||||
return Ok(());
|
||||
}
|
||||
ctx.globals().set(
|
||||
"petAudioVoices",
|
||||
serde_json::to_string(&self.voices).map_err(|_| rquickjs::Error::Unknown)?,
|
||||
)?;
|
||||
ctx.globals().set("petAudioStart", self.sample)?;
|
||||
ctx.globals().set("petAudioLength", end - self.sample)?;
|
||||
let json: String =
|
||||
ctx.eval("pet.pcm(petAudioVoices, petAudioStart, petAudioLength, 48000)")?;
|
||||
let channels: [Vec<f32>; 2] =
|
||||
serde_json::from_str(&json).map_err(|_| rquickjs::Error::Unknown)?;
|
||||
if channels[0].len() != end - self.sample || channels[1].len() != end - self.sample {
|
||||
return Err(rquickjs::Error::Unknown);
|
||||
}
|
||||
target
|
||||
.send(channels)
|
||||
.map_err(|_| rquickjs::Error::Unknown)?;
|
||||
self.sample = end;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
pub fn start(session: Option<String>) -> std::io::Result<Self> {
|
||||
let (tx, rx) = mpsc::sync_channel(128);
|
||||
@@ -160,6 +226,7 @@ fn run(
|
||||
}
|
||||
}
|
||||
let mut saved_at = None;
|
||||
let mut audio_cursor: Option<AudioCursor> = None;
|
||||
for command in rx {
|
||||
// A delayed host can ask for up to 300 fixed ticks (ten seconds).
|
||||
// The worker remains interruptible without treating legitimate catch-up
|
||||
@@ -198,6 +265,7 @@ fn run(
|
||||
waiting,
|
||||
width,
|
||||
height,
|
||||
audio,
|
||||
} => {
|
||||
ctx.globals().set("timeMs", time_ms + offset_ms)?;
|
||||
ctx.globals().set("motion", motion)?;
|
||||
@@ -213,6 +281,37 @@ fn run(
|
||||
return Err(rquickjs::Error::Unknown);
|
||||
}
|
||||
frame.host_time_ms = time_ms;
|
||||
if let Some(target) = audio.filter(Target::active) {
|
||||
*deadline.lock().map_err(|_| rquickjs::Error::Unknown)? =
|
||||
Instant::now() + Duration::from_millis(500);
|
||||
if audio_cursor
|
||||
.as_ref()
|
||||
.is_none_or(|c| !c.target.same_stream(&target))
|
||||
{
|
||||
audio_cursor = Some(AudioCursor {
|
||||
target: target.clone(),
|
||||
sample: (frame.time_ms * audio::SAMPLE_RATE as f64 / 1000.0)
|
||||
.floor()
|
||||
as usize,
|
||||
voices: Vec::new(),
|
||||
});
|
||||
}
|
||||
if audio_cursor
|
||||
.as_mut()
|
||||
.expect("initialized audio cursor")
|
||||
.present(&ctx, &target, frame.time_ms)
|
||||
.is_err()
|
||||
{
|
||||
// Sound failure cannot stop telemetry or its recording.
|
||||
let _ = ctx.catch();
|
||||
target.fail();
|
||||
audio_cursor = None;
|
||||
}
|
||||
*deadline.lock().map_err(|_| rquickjs::Error::Unknown)? =
|
||||
Instant::now() + Duration::from_secs(5);
|
||||
} else {
|
||||
audio_cursor = None;
|
||||
}
|
||||
if let Ok(mut slot) = output.lock() {
|
||||
*slot = Some(Ok(frame));
|
||||
}
|
||||
@@ -260,6 +359,10 @@ mod tests {
|
||||
}
|
||||
|
||||
fn frame(worker: &Worker, at: f64) -> Raster {
|
||||
frame_with_audio(worker, at, None)
|
||||
}
|
||||
|
||||
fn frame_with_audio(worker: &Worker, at: f64, audio: Option<Target>) -> Raster {
|
||||
worker
|
||||
.tx
|
||||
.send(Command::Advance {
|
||||
@@ -268,6 +371,7 @@ mod tests {
|
||||
waiting: true,
|
||||
width: 78,
|
||||
height: 22,
|
||||
audio,
|
||||
})
|
||||
.unwrap();
|
||||
let until = Instant::now() + Duration::from_secs(10);
|
||||
@@ -291,6 +395,59 @@ mod tests {
|
||||
assert!(!matches!(*output.lock().unwrap(), Some(Err(()))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_cursor_preserves_the_shared_score_across_buffer_boundaries() {
|
||||
let runtime = Runtime::new().unwrap();
|
||||
let context = Context::full(&runtime).unwrap();
|
||||
let (sink, packets) = audio::Output::capture();
|
||||
context.with(|ctx| {
|
||||
let points: Vec<Vec<f64>> = include_str!("../ambient_life/whale-points.tsv")
|
||||
.lines()
|
||||
.map(|line| line.split_whitespace().map(|s| s.parse().unwrap()).collect())
|
||||
.collect();
|
||||
ctx.globals().set("points", serde_json::to_string(&points).unwrap()).unwrap();
|
||||
ctx.eval::<(), _>(include_bytes!("pet-native.js").as_slice()).unwrap();
|
||||
ctx.eval::<(), _>(r#"globalThis.pet = new PetNative(points, '', '[]', true);
|
||||
pet.observeEngine('{"event":"approval_required","id":"audio-test"}', 0);
|
||||
globalThis.allVoices = [];"#).unwrap();
|
||||
let mut cursor = AudioCursor { target: sink.target(), sample: 0, voices: Vec::new() };
|
||||
let mut received = Vec::new();
|
||||
for at in [400, 800, 1200, 1600] {
|
||||
ctx.globals().set("timeMs", at).unwrap();
|
||||
ctx.eval::<(), _>("pet.advanceEngine(timeMs,false,true); allVoices.push(...JSON.parse(pet.snapshot()).voices)").unwrap();
|
||||
let time: f64 = ctx.eval("JSON.parse(pet.snapshot()).timeMs").unwrap();
|
||||
cursor.present(&ctx, &sink.target(), time).unwrap();
|
||||
received.extend(packets.try_recv().unwrap().bytes);
|
||||
}
|
||||
let expected: String = ctx.eval(format!("pet.pcm(JSON.stringify(allVoices),0,{},48000)", cursor.sample)).unwrap();
|
||||
let [left, right]: [Vec<f32>; 2] = serde_json::from_str(&expected).unwrap();
|
||||
let interleaved: Vec<_> = left.iter().zip(&right)
|
||||
.flat_map(|(l, r)| [*l, *r]).flat_map(f32::to_le_bytes).collect();
|
||||
assert!(left.iter().any(|s| s.abs() > 0.001), "fixture must exercise actual voices");
|
||||
assert_eq!(received, interleaved, "host chunking changed the shared PCM");
|
||||
// Reopening a stream at the current clock cannot replay its past.
|
||||
let (reopened, packets) = audio::Output::capture();
|
||||
assert!(!sink.target().same_stream(&reopened.target()));
|
||||
cursor.voices.clear();
|
||||
cursor.present(&ctx, &reopened.target(), cursor.sample as f64 / 48.0).unwrap();
|
||||
assert!(packets.try_recv().is_err());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sound_sink_failure_does_not_stop_the_world() {
|
||||
let worker = Worker::start(None).unwrap();
|
||||
let (sink, packets) = audio::Output::capture();
|
||||
drop(packets);
|
||||
frame_with_audio(&worker, 0.0, Some(sink.target()));
|
||||
let before = frame_with_audio(&worker, 400.0, Some(sink.target()));
|
||||
assert!(sink.failed());
|
||||
let after = frame_with_audio(&worker, 800.0, Some(sink.target()));
|
||||
assert!(after.time_ms > before.time_ms);
|
||||
assert!(after.hollow, "sound failure is not observed telemetry");
|
||||
finish(worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_checkpoint_reopens_in_the_actual_worker_without_reviving_an_approval() {
|
||||
let _guard = crate::artifacts::TEST_ARTIFACT_SESSIONS_GUARD
|
||||
|
||||
@@ -7,7 +7,7 @@ browser layers, circulating traffic and open decision junctions. Original
|
||||
recordings retain version 1 behavior; new habitats use version 2. This slice
|
||||
replaces the old `≈≈>` / spray / fluke glyph sequence. The explicit Watch panel
|
||||
adds foreground telemetry through the same world and score used by native and
|
||||
web hosts. Rust audio output remains unfinished.
|
||||
web hosts, with opt-in stereo PCM output through FFmpeg's `ffplay`.
|
||||
|
||||
## Ownership
|
||||
|
||||
@@ -121,7 +121,24 @@ limit. Full-tape retention is still bounded by the 64 MiB worker and the core's
|
||||
`/workbar watch export` writes a new immutable `artifacts/pet/replay-<id>.json`
|
||||
and reports its path through the existing toast system and transcript. Import it in the web
|
||||
habitat to replay the accepted telemetry and interactions. Export is available
|
||||
after opening Watch in a saved session. TUI audio output remains unfinished.
|
||||
after opening Watch in a saved session.
|
||||
|
||||
`/workbar watch sound on|off` controls app-local, default-off sound. The existing
|
||||
QuickJS worker retains current score voices and calls the shared `PetNative.pcm`
|
||||
renderer at 48 kHz. A single `ffplay` child receives bounded Float32LE stereo
|
||||
packets through a four-slot queue. There is no second score, clock or event
|
||||
interpreter. PCM rendering has a separate 500 ms interrupt budget; sound failure
|
||||
does not invalidate the world or its recording. Muting, obscuring Watch, quiet
|
||||
mode, stale frames and session changes stop the child and queued sound. The
|
||||
audio thread reaps it; the UI never waits for playback or a pipe write. Delayed
|
||||
and excess packets are discarded. A slow player cannot build an unbounded
|
||||
backlog or turn a temporary stall into a permanently muted session. Reopening
|
||||
begins at the current creature clock.
|
||||
|
||||
FFmpeg is optional and is never downloaded by the application. Missing `ffplay`,
|
||||
or device failure mutes sound with a translated warning;
|
||||
Watch remains usable. Sound status shares the existing caption only when it
|
||||
fits after the full semantic and unknown-coverage cues.
|
||||
|
||||
The live Runtime SSE adapter and this foreground protocol adapter consume
|
||||
different existing input contracts; they share event-v1 projection semantics,
|
||||
@@ -135,7 +152,8 @@ passes, and the real PTY walkthrough covers Watch selection, keyboard, mouse
|
||||
and resizing down to 40x12. Receipts and source hashes are in
|
||||
`CW/artifacts/pet-review-20260912/`. Hosted macOS testing on the initial branch ran 15,418 tests with one Watch-tab
|
||||
golden mismatch; its header is corrected in the transformation update. The
|
||||
updated head still needs its own hosted verdict.
|
||||
subsequent `e69e99b` passed the applicable Linux, macOS and Windows product tests,
|
||||
lint and security checks. Later changes require their own hosted verdict.
|
||||
|
||||
Local render fixtures exercise the actual ocean, glyph and ambient-life modules
|
||||
with literal shell enums extracted from `underwater.rs`; they do not run an
|
||||
|
||||
@@ -40,6 +40,13 @@ save/reopen, immutable `/workbar watch export`, and import that exported JSON in
|
||||
the browser. Unknown must remain legible; stale approvals must not resurrect.
|
||||
Use a disposable session and synthetic/loopback telemetry for this assignment.
|
||||
|
||||
With FFmpeg's `ffplay` on PATH, explicitly enable `/workbar watch sound on`.
|
||||
Check sound off/on, switching away from Watch and back, quiet mode, and a missing
|
||||
or failed player. These must not stop the world or its checkpoint. No historical
|
||||
sound should play after resuming. Unit tests use a capture sink or a disposable
|
||||
child process; they never open an audio device. Actual output and listening
|
||||
quality need separate manual evidence.
|
||||
|
||||
## Apple and Kotlin
|
||||
|
||||
```sh
|
||||
@@ -78,8 +85,9 @@ open junctions; the same particle identities return to the whale at rest.
|
||||
- Actual TUI: bounded worker, persistence/export/reopen/corruption and two-hour
|
||||
continuation exercised in a PTY. Five persistence, eleven paste and nine artifact
|
||||
guard tests passed in the product. The initial hosted macOS suite ran 15,418
|
||||
tests with one outdated Watch-tab golden; that header is corrected in this update.
|
||||
A fresh hosted verdict is still required.
|
||||
tests with one outdated Watch-tab golden; that header was corrected. Subsequent
|
||||
hosted product CI on `e69e99b` passed Linux, macOS and Windows tests, Rust lint
|
||||
and the applicable safety/security checks. Each later commit needs its own verdict.
|
||||
- Apple: eight checkpoint workflows passed; iOS Simulator exercised two-hour
|
||||
restoration and visible corruption recovery without overwriting the damaged file.
|
||||
- The updated Apple checkpoint workflows and iOS Simulator build pass. Earlier
|
||||
|
||||
+10
-1
@@ -45,6 +45,15 @@ cd pet
|
||||
three ports. The script fails for a missing tool or failed build; it does not
|
||||
silently call an omitted platform verified. See [QA.md](QA.md) for native builds.
|
||||
|
||||
In the full terminal, `/workbar watch sound on` enables the shared score and
|
||||
`/workbar watch sound off` mutes it. Sound starts off each time the application
|
||||
opens. Install FFmpeg with `ffplay` on PATH to use this optional output; Watch
|
||||
and recording work without it. The terminal streams the core's stereo 48 kHz
|
||||
PCM to one player process. Hiding Watch, opening a modal, quiet mode, or stale
|
||||
telemetry presentation suspends output. Reopening starts at the current clock,
|
||||
without playing the intervening history. Player failure mutes sound and reports
|
||||
a warning while the world continues. `/workbar watch sound` shows its status.
|
||||
|
||||
## One source of meaning
|
||||
|
||||
`src/core/pet-telemetry.ts` derives 400 ms buckets from normalized Whalesong
|
||||
@@ -126,6 +135,6 @@ seconds, so a crash may lose work since the last successful save.
|
||||
Native habitats are limited to 8 MiB; the QuickJS worker has a 64 MiB memory
|
||||
limit. Two-hour restoration has been exercised, but serializing full long tapes
|
||||
still costs seconds in QuickJS. History rotation and long-session performance
|
||||
remain open. TUI audio output, Android Compose/app/audio, macOS popover inspection,
|
||||
remain open. Android Compose/app/audio, macOS popover inspection,
|
||||
authenticated Runtime attachment, content-driven forms and final listening/power quality
|
||||
are unfinished. This branch is a reviewable prototype, not a release candidate.
|
||||
Reference in new issue
Block a user